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.
- package/CAPABILITY.md +147 -68
- package/LICENSE.md +21 -21
- package/README.md +108 -106
- 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 +146 -149
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Split a PDF into one file per page, per page range, or in fixed-size chunks.
|
|
3
|
+
|
|
4
|
+
Ranges are 1-based and inclusive; "6-" runs to the last page, "-4" starts at
|
|
5
|
+
page 1. Without --ranges or --every every page becomes its own file. Each part
|
|
6
|
+
copies the source metadata and reports its page count so the caller can check
|
|
7
|
+
the split before delivering it.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
|
|
10
|
+
3 refused (range outside the document, or output path collision).
|
|
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
|
+
|
|
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_ranges(spec, page_count):
|
|
47
|
+
spans = []
|
|
48
|
+
for part in spec.split(","):
|
|
49
|
+
part = part.strip()
|
|
50
|
+
if not part:
|
|
51
|
+
continue
|
|
52
|
+
if "-" not in part:
|
|
53
|
+
start = end = int(part)
|
|
54
|
+
else:
|
|
55
|
+
first, last = part.split("-", 1)
|
|
56
|
+
start = int(first) if first.strip() else 1
|
|
57
|
+
end = int(last) if last.strip() else page_count
|
|
58
|
+
if start < 1 or end > page_count or start > end:
|
|
59
|
+
fail(f"range {part!r} is outside pages 1-{page_count}", code=3)
|
|
60
|
+
spans.append((start, end))
|
|
61
|
+
if not spans:
|
|
62
|
+
fail("--ranges listed no usable range")
|
|
63
|
+
spans.sort()
|
|
64
|
+
for previous, current in zip(spans, spans[1:]):
|
|
65
|
+
if current[0] <= previous[1]:
|
|
66
|
+
fail(f"ranges overlap: {previous[0]}-{previous[1]} and {current[0]}-{current[1]}", code=3)
|
|
67
|
+
return spans
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main():
|
|
71
|
+
parser = argparse.ArgumentParser(
|
|
72
|
+
description="Split a PDF into page ranges, single pages, or fixed-size chunks.",
|
|
73
|
+
epilog='Example: split_pages.py --input report.pdf --out-dir parts --ranges "1-2,3,4-"',
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("--input", required=True, help="Source PDF")
|
|
76
|
+
parser.add_argument("--out-dir", required=True, help="Directory for the parts")
|
|
77
|
+
parser.add_argument("--ranges", help='1-based inclusive ranges, e.g. "1-2,5,8-"')
|
|
78
|
+
parser.add_argument("--every", type=int, metavar="N", help="Fixed chunk size in pages")
|
|
79
|
+
parser.add_argument("--prefix", help="Filename prefix (default: source stem)")
|
|
80
|
+
parser.add_argument("--json-out", help="Also write the JSON report to this path")
|
|
81
|
+
args = parser.parse_args()
|
|
82
|
+
|
|
83
|
+
if args.ranges and args.every:
|
|
84
|
+
fail("use either --ranges or --every, not both")
|
|
85
|
+
if args.every is not None and args.every < 1:
|
|
86
|
+
fail("--every must be at least 1")
|
|
87
|
+
|
|
88
|
+
source = Path(args.input).expanduser()
|
|
89
|
+
if not source.is_file():
|
|
90
|
+
fail(f"input not found: {source}", code=2)
|
|
91
|
+
out_dir = Path(args.out_dir).expanduser()
|
|
92
|
+
prefix = args.prefix or source.stem
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
document = pymupdf.open(str(source))
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
fail(f"cannot open PDF: {exc}", code=2)
|
|
98
|
+
if document.needs_pass:
|
|
99
|
+
document.close()
|
|
100
|
+
fail("PDF is encrypted; decrypt it before splitting", code=3)
|
|
101
|
+
page_count = document.page_count
|
|
102
|
+
if page_count == 0:
|
|
103
|
+
document.close()
|
|
104
|
+
fail("the PDF has no pages", code=3)
|
|
105
|
+
|
|
106
|
+
if args.ranges:
|
|
107
|
+
spans = parse_ranges(args.ranges, page_count)
|
|
108
|
+
elif args.every:
|
|
109
|
+
spans = [(start, min(start + args.every - 1, page_count)) for start in range(1, page_count + 1, args.every)]
|
|
110
|
+
else:
|
|
111
|
+
spans = [(number, number) for number in range(1, page_count + 1)]
|
|
112
|
+
|
|
113
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
parts = []
|
|
115
|
+
for start, end in spans:
|
|
116
|
+
name = f"{prefix}-p{start}.pdf" if start == end else f"{prefix}-p{start}-{end}.pdf"
|
|
117
|
+
target = out_dir / name
|
|
118
|
+
if target.resolve() == source.resolve():
|
|
119
|
+
document.close()
|
|
120
|
+
fail(f"refusing to overwrite the source PDF: {target}", code=3)
|
|
121
|
+
part = pymupdf.open()
|
|
122
|
+
part.insert_pdf(document, from_page=start - 1, to_page=end - 1)
|
|
123
|
+
try:
|
|
124
|
+
part.set_metadata(document.metadata or {})
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
try:
|
|
128
|
+
part.save(str(target), garbage=3, deflate=True)
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
part.close()
|
|
131
|
+
document.close()
|
|
132
|
+
fail(f"cannot write {target}: {exc}", code=2)
|
|
133
|
+
written_pages = part.page_count
|
|
134
|
+
part.close()
|
|
135
|
+
parts.append({"file": str(target), "first_page": start, "last_page": end, "pages": written_pages})
|
|
136
|
+
|
|
137
|
+
document.close()
|
|
138
|
+
result = {
|
|
139
|
+
"ok": True,
|
|
140
|
+
"input": str(source),
|
|
141
|
+
"source_pages": page_count,
|
|
142
|
+
"out_dir": str(out_dir),
|
|
143
|
+
"parts": parts,
|
|
144
|
+
"parts_total_pages": sum(part["pages"] for part in parts),
|
|
145
|
+
}
|
|
146
|
+
if result["parts_total_pages"] != page_count:
|
|
147
|
+
fail(
|
|
148
|
+
f"wrote {result['parts_total_pages']} pages but the source has {page_count}; check the ranges",
|
|
149
|
+
code=3,
|
|
150
|
+
)
|
|
151
|
+
emit(result, args.json_out)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stamp a running footer, header, or label onto selected pages of a PDF.
|
|
3
|
+
|
|
4
|
+
Draws the text on a transparent overlay with reportlab, then merges that overlay
|
|
5
|
+
into each chosen page with pypdf. The text supports {page} and {pages}, so one
|
|
6
|
+
template produces "Page 3 of 12" per page. Nothing in the source content stream
|
|
7
|
+
is rewritten, which keeps existing fonts, links, and form widgets intact.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
|
|
10
|
+
3 refused (encrypted document, bad page selection, or source overwrite).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
22
|
+
try:
|
|
23
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
24
|
+
except (AttributeError, ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
from pypdf import PdfReader, PdfWriter
|
|
28
|
+
from reportlab.lib.colors import HexColor
|
|
29
|
+
from reportlab.pdfgen import canvas
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import pymupdf
|
|
33
|
+
except ImportError: # pragma: no cover - PyMuPDF is a required dependency
|
|
34
|
+
import fitz as pymupdf # type: ignore[no-redef]
|
|
35
|
+
|
|
36
|
+
POSITIONS = {
|
|
37
|
+
"bottom-left": "left",
|
|
38
|
+
"bottom-center": "center",
|
|
39
|
+
"bottom-right": "right",
|
|
40
|
+
"top-left": "left",
|
|
41
|
+
"top-center": "center",
|
|
42
|
+
"top-right": "right",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def emit(value, out=None):
|
|
47
|
+
text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
48
|
+
if out:
|
|
49
|
+
target = Path(out).expanduser()
|
|
50
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
target.write_text(text + "\n", encoding="utf-8")
|
|
52
|
+
print(text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fail(message, code=1):
|
|
56
|
+
print(json.dumps({"ok": False, "error": message}, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
57
|
+
raise SystemExit(code)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_pages(spec, page_count):
|
|
61
|
+
if spec.strip().lower() in {"all", ""}:
|
|
62
|
+
return list(range(1, page_count + 1))
|
|
63
|
+
selected = []
|
|
64
|
+
for part in spec.split(","):
|
|
65
|
+
part = part.strip()
|
|
66
|
+
if not part:
|
|
67
|
+
continue
|
|
68
|
+
if "-" not in part:
|
|
69
|
+
selected.append(int(part))
|
|
70
|
+
else:
|
|
71
|
+
first, last = part.split("-", 1)
|
|
72
|
+
selected.extend(range(int(first or 1), int(last or page_count) + 1))
|
|
73
|
+
for number in selected:
|
|
74
|
+
if number < 1 or number > page_count:
|
|
75
|
+
fail(f"page {number} is outside 1-{page_count}", code=3)
|
|
76
|
+
return sorted(set(selected))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
parser = argparse.ArgumentParser(
|
|
81
|
+
description="Draw a text stamp on selected pages and merge it over the page content.",
|
|
82
|
+
epilog='Example: stamp_overlay.py --input report.pdf --out stamped.pdf --text "Page {page} of {pages}" --position bottom-right',
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument("--input", required=True, help="Source PDF; it is never modified")
|
|
85
|
+
parser.add_argument("--out", required=True, help="Destination PDF")
|
|
86
|
+
parser.add_argument("--text", required=True, help='Stamp text; {page} and {pages} are replaced, e.g. "Confidential - Page {page}/{pages}"')
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--position",
|
|
89
|
+
choices=sorted(POSITIONS),
|
|
90
|
+
default="bottom-center",
|
|
91
|
+
help="Anchor of the stamp (default: bottom-center)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--font", default="Helvetica", help="reportlab base-14 font name (default: Helvetica)")
|
|
94
|
+
parser.add_argument("--font-size", type=float, default=9.0, help="Font size in points (default: 9)")
|
|
95
|
+
parser.add_argument("--color", default="#666666", help="Hex text colour (default: #666666)")
|
|
96
|
+
parser.add_argument("--margin", type=float, default=28.0, help="Page-edge margin in points (default: 28)")
|
|
97
|
+
parser.add_argument("--opacity", type=float, default=1.0, help="Text opacity 0-1 (default: 1)")
|
|
98
|
+
parser.add_argument("--pages", default="all", help='1-based selection, e.g. "1,3-5" (default: all)')
|
|
99
|
+
parser.add_argument("--render-dir", help="Rasterise the stamped pages here for a visual check")
|
|
100
|
+
parser.add_argument("--dpi", type=int, default=110, help="Render resolution (default: 110)")
|
|
101
|
+
parser.add_argument("--json-out", help="Also write the JSON report to this path")
|
|
102
|
+
args = parser.parse_args()
|
|
103
|
+
|
|
104
|
+
source = Path(args.input).expanduser()
|
|
105
|
+
if not source.is_file():
|
|
106
|
+
fail(f"input not found: {source}", code=2)
|
|
107
|
+
out = Path(args.out).expanduser()
|
|
108
|
+
if out.resolve() == source.resolve():
|
|
109
|
+
fail("refusing to overwrite the source PDF; choose a different --out", code=3)
|
|
110
|
+
if args.font_size <= 0 or args.font_size > 96:
|
|
111
|
+
fail("--font-size must be between 0 and 96")
|
|
112
|
+
if not 0 < args.opacity <= 1:
|
|
113
|
+
fail("--opacity must be greater than 0 and at most 1")
|
|
114
|
+
if args.margin < 0 or args.margin > 200:
|
|
115
|
+
fail("--margin must be between 0 and 200 points")
|
|
116
|
+
try:
|
|
117
|
+
colour = HexColor(args.color)
|
|
118
|
+
except Exception:
|
|
119
|
+
fail("--color must be a hex colour like #666666")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
reader = PdfReader(str(source))
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
fail(f"cannot open PDF: {exc}", code=2)
|
|
125
|
+
if reader.is_encrypted:
|
|
126
|
+
fail("PDF is encrypted; decrypt it before stamping", code=3)
|
|
127
|
+
page_count = len(reader.pages)
|
|
128
|
+
selected = parse_pages(args.pages, page_count)
|
|
129
|
+
|
|
130
|
+
writer = PdfWriter()
|
|
131
|
+
stamped = []
|
|
132
|
+
for index, page in enumerate(reader.pages, start=1):
|
|
133
|
+
if index in selected:
|
|
134
|
+
box = page.mediabox
|
|
135
|
+
width = float(box.width)
|
|
136
|
+
height = float(box.height)
|
|
137
|
+
label = args.text.format(page=index, pages=page_count)
|
|
138
|
+
buffer = io.BytesIO()
|
|
139
|
+
stamp = canvas.Canvas(buffer, pagesize=(width, height))
|
|
140
|
+
stamp.setFillColor(colour)
|
|
141
|
+
stamp.setFont(args.font, args.font_size)
|
|
142
|
+
stamp.setFillAlpha(args.opacity)
|
|
143
|
+
text_width = stamp.stringWidth(label, args.font, args.font_size)
|
|
144
|
+
anchor = POSITIONS[args.position]
|
|
145
|
+
if anchor == "left":
|
|
146
|
+
x = args.margin
|
|
147
|
+
elif anchor == "right":
|
|
148
|
+
x = width - args.margin - text_width
|
|
149
|
+
else:
|
|
150
|
+
x = (width - text_width) / 2
|
|
151
|
+
baseline = args.margin if args.position.startswith("bottom") else height - args.margin - args.font_size * 0.35
|
|
152
|
+
stamp.drawString(x, baseline, label)
|
|
153
|
+
stamp.save()
|
|
154
|
+
buffer.seek(0)
|
|
155
|
+
overlay = PdfReader(buffer).pages[0]
|
|
156
|
+
page.merge_page(overlay)
|
|
157
|
+
stamped.append({"page": index, "text": label, "x": round(x, 2), "y": round(baseline, 2)})
|
|
158
|
+
writer.add_page(page)
|
|
159
|
+
|
|
160
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
try:
|
|
162
|
+
with out.open("wb") as handle:
|
|
163
|
+
writer.write(handle)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
fail(f"cannot write {out}: {exc}", code=2)
|
|
166
|
+
|
|
167
|
+
rendered = []
|
|
168
|
+
if args.render_dir:
|
|
169
|
+
if args.dpi < 24 or args.dpi > 600:
|
|
170
|
+
fail("--dpi must be between 24 and 600")
|
|
171
|
+
target_dir = Path(args.render_dir).expanduser()
|
|
172
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
zoom = args.dpi / 72.0
|
|
174
|
+
with pymupdf.open(str(out)) as rendered_doc:
|
|
175
|
+
for page_number in selected:
|
|
176
|
+
page = rendered_doc[page_number - 1]
|
|
177
|
+
target = target_dir / f"page-{page_number}.png"
|
|
178
|
+
page.get_pixmap(matrix=pymupdf.Matrix(zoom, zoom), alpha=False).save(str(target))
|
|
179
|
+
rendered.append(str(target))
|
|
180
|
+
|
|
181
|
+
result = {
|
|
182
|
+
"ok": True,
|
|
183
|
+
"input": str(source),
|
|
184
|
+
"output": str(out),
|
|
185
|
+
"pages": page_count,
|
|
186
|
+
"stamped_pages": stamped,
|
|
187
|
+
"position": args.position,
|
|
188
|
+
"font": args.font,
|
|
189
|
+
"font_size": args.font_size,
|
|
190
|
+
"color": args.color,
|
|
191
|
+
"rendered_pages": rendered,
|
|
192
|
+
}
|
|
193
|
+
if len(result["stamped_pages"]) != len(selected):
|
|
194
|
+
fail(f"stamped {len(result['stamped_pages'])} pages but selected {len(selected)}", code=3)
|
|
195
|
+
emit(result, args.json_out)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Resolve an `office-annotations` region on a PDF
|
|
2
|
+
|
|
3
|
+
`page` is the 1-based page of the file, and `region × page.rect` is the real rectangle in the page's coordinate system. Convert once; then everything you do — read, redact, stamp, fill — uses that rect.
|
|
4
|
+
|
|
5
|
+
## 1. Reproduce the page first
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
"<python>" "<cli>" pdf render --input "<file.pdf>" --out-dir "<render>" --dpi 110
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Open `"<render>/page-<N>.png"` with `read_image`. The geometry is only meaningful against the page the user saw, so never resolve a region on a page you have not rendered.
|
|
12
|
+
|
|
13
|
+
## 2. Convert the region and read what it covers
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
"<python>" - <<'PY'
|
|
17
|
+
import pymupdf
|
|
18
|
+
doc = pymupdf.open("in.pdf")
|
|
19
|
+
page = doc[0] # page 1
|
|
20
|
+
r = page.rect
|
|
21
|
+
region = (0.064, 0.318, 0.891, 0.153) # left, top, width, height
|
|
22
|
+
left, top, width, height = region
|
|
23
|
+
rect = pymupdf.Rect(r.x0 + left * r.width, r.y0 + top * r.height,
|
|
24
|
+
r.x0 + (left + width) * r.width, r.y0 + (top + height) * r.height)
|
|
25
|
+
print("rect", rect)
|
|
26
|
+
print("words:", " ".join(w[4] for w in page.get_text("words", clip=rect)))
|
|
27
|
+
print("textbox:", repr(page.get_textbox(rect)[:300]))
|
|
28
|
+
print("images:", page.get_image_info(clip=rect))
|
|
29
|
+
PY
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Compare the words with the annotation's `text`. If `text` is present and those words are not in the region, trust `text` and say the region was off — do not shift the rectangle to make it land. If `imageName` came with the payload, open that screenshot with `read_image` to see exactly what was marked.
|
|
33
|
+
|
|
34
|
+
## 3. Pick the smallest operation that satisfies `requirement`
|
|
35
|
+
|
|
36
|
+
| What the region covers | Operation |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| A metadata line, title, author | `scripts/metadata.py --set ...` |
|
|
39
|
+
| A whole block to delete | `scripts/redact_region.py` |
|
|
40
|
+
| A widget or a form label | `scripts/forms.py fill` / `inspect` |
|
|
41
|
+
| A footer, header, or label to add | `scripts/stamp_overlay.py` |
|
|
42
|
+
| Page order, rotation, deletion | `scripts/page_ops.py` or `scripts/split_pages.py` |
|
|
43
|
+
| A single word or sentence to change | redact the old line, then stamp the new text (below) |
|
|
44
|
+
| A picture to replace | overlay a cover rect and draw the new image with reportlab |
|
|
45
|
+
|
|
46
|
+
A requirement like "make this sentence shorter" is a content edit; do not rebuild the whole page for it when the block is one line.
|
|
47
|
+
|
|
48
|
+
## 4. Patch one line in place
|
|
49
|
+
|
|
50
|
+
Delete the old glyphs first, then draw the replacement at the same baseline. Covering without deleting leaves the original text extractable.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
"<python>" - <<'PY'
|
|
54
|
+
import io
|
|
55
|
+
import pymupdf
|
|
56
|
+
from pypdf import PdfReader, PdfWriter
|
|
57
|
+
from reportlab.pdfgen import canvas
|
|
58
|
+
|
|
59
|
+
old = "Revenue grew 18.4% year over year."
|
|
60
|
+
new = "Revenue grew 19% year over year."
|
|
61
|
+
doc = pymupdf.open("in.pdf")
|
|
62
|
+
page = doc[3] # page 4
|
|
63
|
+
hits = page.search_for(old)
|
|
64
|
+
assert hits, "text not found; re-read the page"
|
|
65
|
+
rect = hits[0] + (-1, -1, 1, 1) # a little air around the line
|
|
66
|
+
page.add_redact_annot(rect, fill=None)
|
|
67
|
+
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_NONE,
|
|
68
|
+
graphics=pymupdf.PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED)
|
|
69
|
+
doc.save("cleaned.pdf", garbage=3, deflate=True)
|
|
70
|
+
|
|
71
|
+
reader = PdfReader("cleaned.pdf")
|
|
72
|
+
writer = PdfWriter()
|
|
73
|
+
with pymupdf.open("in.pdf") as source:
|
|
74
|
+
font_size = 11
|
|
75
|
+
for index, page in enumerate(reader.pages):
|
|
76
|
+
if index == 3:
|
|
77
|
+
buffer = io.BytesIO()
|
|
78
|
+
c = canvas.Canvas(buffer, pagesize=(float(page.mediabox.width), float(page.mediabox.height)))
|
|
79
|
+
c.setFont("Helvetica", font_size)
|
|
80
|
+
c.drawString(float(rect.x0), float(page.mediabox.height) - float(rect.y0) - font_size, new)
|
|
81
|
+
c.save()
|
|
82
|
+
buffer.seek(0)
|
|
83
|
+
page.merge_page(PdfReader(buffer).pages[0])
|
|
84
|
+
writer.add_page(page)
|
|
85
|
+
with open("out.pdf", "wb") as handle:
|
|
86
|
+
writer.write(handle)
|
|
87
|
+
PY
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Line height and font size must match the original text; read them from the original spans (`page.get_text("dict")` → blocks → lines → spans → `size`, `font`, `origin`). A different font or size is visible in the render and reads as a patch job.
|
|
91
|
+
|
|
92
|
+
## 5. When the payload cannot be tied to an object
|
|
93
|
+
|
|
94
|
+
Say which part you could not locate: the render page, the words you found under the region, and the item that is missing. Then ask for the text of the target or a clearer region. Genuinely blocked cases and their resolutions are in `references/limits.md` — name the missing capability and what the user can do about it, never a bare "unsupported".
|
|
95
|
+
|
|
96
|
+
## 6. Re-render and confirm
|
|
97
|
+
|
|
98
|
+
Apply the edit, render the same page, and `read_image` it again. The region must show the requested result, the neighbouring text must be untouched, and the layout must still hold. Then check extraction (`pdf read`) so the change is real in the text layer, not only in pixels. Report what you resolved back to the user in the same terms they annotated with: page number and a one-line description of the change.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Create a PDF
|
|
2
|
+
|
|
3
|
+
A new PDF is a set of pages, each holding a content stream plus fonts and resources. Two routes exist here, and both end with the same verification.
|
|
4
|
+
|
|
5
|
+
## Fast path: the helper CLI spec
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
"<python>" "<cli>" pdf create --spec "<spec.json>" --out "<output.pdf>"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"title": "Field report",
|
|
14
|
+
"author": "Ada Lovelace",
|
|
15
|
+
"page_size": "A4",
|
|
16
|
+
"blocks": [
|
|
17
|
+
{ "type": "title", "text": "Field report" },
|
|
18
|
+
{ "type": "paragraph", "text": "One sentence of body copy." },
|
|
19
|
+
{ "type": "bullets", "items": ["First", "Second"] },
|
|
20
|
+
{ "type": "table", "headers": ["Item", "Value"], "rows": [["Pump", "3"]] },
|
|
21
|
+
{ "type": "page_break" }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Blocks: `title`, `heading` (`level` 1–2), `paragraph`, `quote`, `bullets`, `numbered`, `table` (`headers`, `rows`), `image` (`path`, `width` inches, height derived at 0.66 aspect), `page_break`, `spacer` (`height` inches). Use `pages: [{ "blocks": [...], "break": true }]` when the page grouping matters. `page_size` accepts `A4` or `Letter`.
|
|
27
|
+
|
|
28
|
+
## Direct path: reportlab
|
|
29
|
+
|
|
30
|
+
Reach for reportlab when the layout does not fit the block model — columns, absolute placement, custom rules, rotated text.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
"<python>" - <<'PY'
|
|
34
|
+
from reportlab.lib.pagesizes import A4
|
|
35
|
+
from reportlab.pdfgen import canvas
|
|
36
|
+
from reportlab.lib.units import mm
|
|
37
|
+
|
|
38
|
+
c = canvas.Canvas("out.pdf", pagesize=A4)
|
|
39
|
+
width, height = A4
|
|
40
|
+
c.setTitle("Field report")
|
|
41
|
+
c.setFont("Helvetica-Bold", 18)
|
|
42
|
+
c.drawString(22 * mm, height - 30 * mm, "Field report")
|
|
43
|
+
c.setFont("Helvetica", 11)
|
|
44
|
+
text = c.beginText(22 * mm, height - 45 * mm)
|
|
45
|
+
for line in ["One sentence of body copy.", "A second line."]:
|
|
46
|
+
text.textLine(line)
|
|
47
|
+
c.drawText(text)
|
|
48
|
+
c.showPage()
|
|
49
|
+
c.save()
|
|
50
|
+
PY
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`drawString` takes a baseline point measured from the bottom-left corner, not the top. Convert top-down coordinates once (for a page height `h`, baseline `y = h - top - font_size`), or every following measurement will drift.
|
|
54
|
+
|
|
55
|
+
## Fonts
|
|
56
|
+
|
|
57
|
+
Base-14 fonts (`Helvetica`, `Times-Roman`, `Courier`) need no embedding and cover Latin text. Non-Latin text needs a real font file registered with `pdfmetrics.registerFont(TTFont("Body", "<path>.ttf"))`; a font that cannot render a glyph produces a blank box in the output, which the render step will show. The CLI tries the first available CJK system font, and says so when none exists.
|
|
58
|
+
|
|
59
|
+
## Quality rules
|
|
60
|
+
|
|
61
|
+
- One heading scale, one body size, one margin system per document.
|
|
62
|
+
- Keep body text inside 12–16 pt leading; below 9 pt text becomes unreliable on print.
|
|
63
|
+
- ASCII hyphens only in prose; do not paste typographic dashes into generated copy unless the source used them.
|
|
64
|
+
- Give every page a consistent header/footer treatment, or none at all.
|
|
65
|
+
- Do not put a table wider than the text column; wrap long cell text instead of letting it clip.
|
|
66
|
+
|
|
67
|
+
## Verify
|
|
68
|
+
|
|
69
|
+
Render and look at every page; a created PDF has no text layer surprises, so problems are layout problems: text past the margin, blocks overlapping, table columns squeezed, a `page_break` landing on an empty page, or a CJK line silently blank.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
"<python>" "<cli>" pdf render --input "out.pdf" --out-dir "render" --dpi 110
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then confirm the structure you intended with `"<python>" "<cli>" pdf read --input "out.pdf"` (page count and per-page text) before `present`.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Merge, split, reorder, rotate, delete pages
|
|
2
|
+
|
|
3
|
+
Pages are objects referenced from a page tree; changing the order or removing a page changes references, not content. Nothing is re-flowed, so a page moved into a document with a different page size keeps its own box and fonts.
|
|
4
|
+
|
|
5
|
+
## Fast path: `scripts/page_ops.py`
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
"<python>" "<skill>/scripts/page_ops.py" --input "<file.pdf>" --out "<file-edited.pdf>" \
|
|
9
|
+
--rotate 90 --pages 0,2 --remove 3 --merge "<other.pdf>" --order 2,0,1
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- Operations run in a fixed order: merge, then order, then remove, then rotate — a command means the same thing every time.
|
|
13
|
+
- Every index is zero-based and addresses the combined page list (input pages first, appended pages after).
|
|
14
|
+
- `--order` keeps and reorders; pages it does not list are dropped, which also makes it the duplicate/select tool when you repeat an index.
|
|
15
|
+
- `--remove` drops indices from the list left by `--order`.
|
|
16
|
+
- `--rotate` takes 90, 180, or 270 clockwise and defaults to every remaining page; `--pages` limits it.
|
|
17
|
+
- `--out` defaults to `<input>-edited.pdf`; the input is never overwritten.
|
|
18
|
+
|
|
19
|
+
The script carries page content streams over as they are and reopens the result to report page count, rotation, and page size per page. When one call cannot express the operation, chain two calls into two output files rather than reusing a name.
|
|
20
|
+
|
|
21
|
+
## Metadata
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
"<python>" "<skill>/scripts/metadata.py" --input "<file.pdf>" --set "/Title=Field report" --set "/Author=Ada" --out "<file-edited.pdf>"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Keys are the document information fields (`Title`, `Author`, `Subject`, `Keywords`, `Creator`, `Producer`, `CreationDate`, `ModDate`), plain or `/Name`, in any case. The script writes a new file and reopens it, so `applied` and `not_applied` tell you what actually persisted.
|
|
28
|
+
|
|
29
|
+
## Split
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
"<python>" "<skill>/scripts/split_pages.py" --input "<file.pdf>" --out-dir "<parts>" --ranges "1-2,3,5-"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
1-based inclusive ranges, `--every N` for equal chunks, default one file per page. The script copies the source metadata, refuses overlapping or out-of-range ranges, and fails if the parts do not add up to the source page count — that check is what catches a typo in the range string. `--prefix` sets the filename stem.
|
|
36
|
+
|
|
37
|
+
## Direct page-tree work
|
|
38
|
+
|
|
39
|
+
Reorder, duplicate, or delete with pypdf when `page_ops.py` does not fit:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
"<python>" - <<'PY'
|
|
43
|
+
from pypdf import PdfReader, PdfWriter
|
|
44
|
+
reader = PdfReader("in.pdf")
|
|
45
|
+
writer = PdfWriter()
|
|
46
|
+
order = [2, 0, 1, 0] # 0-based; repeat an index to duplicate a page
|
|
47
|
+
for index in order:
|
|
48
|
+
writer.add_page(reader.pages[index])
|
|
49
|
+
writer.add_metadata({"/Title": "Reordered"})
|
|
50
|
+
with open("out.pdf", "wb") as handle:
|
|
51
|
+
writer.write(handle)
|
|
52
|
+
print("pages", len(writer.pages))
|
|
53
|
+
PY
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`add_page` copies the page and its resources into the new file; annotations that point across pages (links) can break when their target is removed. After a reorder or deletion, check the links and bookmarks on the render, not only the text.
|
|
57
|
+
|
|
58
|
+
## Rotate
|
|
59
|
+
|
|
60
|
+
Page rotation is a `/Rotate` entry (multiples of 90) applied by viewers; it does not transpose the content stream. `page.rotate(90)` in pypdf, or `page_ops.py --rotate 90 --pages ...`, is enough. If a page was scanned sideways and also carries a stale `/Rotate`, the two add up — the render shows whether you are looking at the wrong one, and `scripts/render_pages.py` reports each page's rotation.
|
|
61
|
+
|
|
62
|
+
## Verify
|
|
63
|
+
|
|
64
|
+
After any structural edit:
|
|
65
|
+
|
|
66
|
+
- compare page counts (`pdf read` or the split report) — one missing page is easy to miss in a long render;
|
|
67
|
+
- render at least the first and last page of every part, and every page you rotated or reordered;
|
|
68
|
+
- for merges, confirm the page size and orientation of the appended pages did not change, and that page numbers or bates stamps do not collide;
|
|
69
|
+
- for deletions of numbered pages, tell the user which numbers were removed, because the remaining page labels will shift.
|
|
70
|
+
|
|
71
|
+
`scripts/render_pages.py --input "<file.pdf>" --region 0,0,1,1 --json-out ...` prints per-page character and drawing counts, which is a cheap way to confirm a page is not blank before you deliver.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# AcroForm inspection and filling
|
|
2
|
+
|
|
3
|
+
A form PDF carries `/AcroForm` in the catalog. Its `/Fields` array holds field dictionaries: `/T` the partial name, `/FT` the type (`Tx` text, `Btn` button, `Ch` choice, `Sig` signature), `/V` the value, `/Ff` flags, `/DA` the default appearance, and `/AP` appearance streams. A widget is the field's visual rectangle (`/Rect`, `/P` parent page, `/AS` its currently selected state name). A field can own several widgets (radio groups), and a widget can be a separate object from its field, reached through `/Parent`. Text in a filled field is usually not in the page content stream — it lives in the widget's `/AP /N` appearance, which is why "the value is in `/V`" and "the page shows the value" are two different facts to check.
|
|
4
|
+
|
|
5
|
+
## Inspect first
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
"<python>" "<skill>/scripts/forms.py" inspect --input "<file.pdf>"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Reports, per field: id/name, type, value, read-only/required, widget count, page numbers, current states for buttons, and whether each widget has an `/AP` stream. Read the whole report before touching values: duplicate names, missing appearance streams, and read-only flags are the usual reason a fill "does nothing".
|
|
12
|
+
|
|
13
|
+
## Fill
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
"<python>" "<skill>/scripts/forms.py" fill --input "<file.pdf>" --out "<file-filled.pdf>" --values "<values.json>"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{ "full_name": "Ada Lovelace", "email": "ada@example.org", "agree": true, "plan": "/basic" }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Text fields take strings, checkboxes take booleans or the state name, radio groups take the state you want selected, choice fields take a string or list. The dictionary is keyed by field name (`/T`, nested names joined with dots). The script fails with a non-zero exit when a name does not exist instead of silently skipping it, when a value is not one of the allowed states, and when the saved file fails the verification below. Pass `--flatten` to bake the values into the page and drop the form.
|
|
24
|
+
|
|
25
|
+
## The discipline that makes a fill real
|
|
26
|
+
|
|
27
|
+
pypdf writes `/V` and rebuilds the widget appearance. Both must be re-checked after saving, because a writer can update one and not the other:
|
|
28
|
+
|
|
29
|
+
1. Reopen the saved file.
|
|
30
|
+
2. For every field you set, read `/V` from the field dictionary and compare it with the requested value.
|
|
31
|
+
3. For every widget, confirm `/AP /N` exists and is a non-empty stream with a `/BBox`; for radio and checkbox groups, confirm `/AS` names a state that exists in `/AP /N`.
|
|
32
|
+
4. Render the page and look at the field.
|
|
33
|
+
|
|
34
|
+
Steps 1–3 catch a stale or missing appearance; step 4 catches a `/V` that is right while the page still shows the old text. The script does 1–3 itself and prints `applied`/`not_applied`; treat `not_applied` as a failure to fix, never as a warning.
|
|
35
|
+
|
|
36
|
+
When you build the check by hand:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
"<python>" - <<'PY'
|
|
40
|
+
from pypdf import PdfReader
|
|
41
|
+
reader = PdfReader("filled.pdf")
|
|
42
|
+
fields = reader.get_fields() or {}
|
|
43
|
+
root = reader.trailer["/Root"].get("/AcroForm")
|
|
44
|
+
for name, field in fields.items():
|
|
45
|
+
widgets = field.get("/Kids") or []
|
|
46
|
+
for widget in widgets:
|
|
47
|
+
states = list((widget.get("/AP", {}).get("/N", {}) or {}).keys())
|
|
48
|
+
print(name, field.get("/V"), widget.get("/AS"), states, "bbox" in str(widget.get("/AP")))
|
|
49
|
+
PY
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Radio groups are the common failure: pypdf updates the group `/V`, but the widget-level `/AS` for the previously selected button can stay behind, so two buttons look selected. The script resynchronises every widget's `/AS` from the group value; if you fill by hand, do the same.
|
|
53
|
+
|
|
54
|
+
## Flatten
|
|
55
|
+
|
|
56
|
+
Flattening is for files that must no longer be editable. Update with `flatten=True` (the value is painted into the page content), then remove every widget annotation (`subtypes="/Widget"`) and drop `/AcroForm` from the catalog. Verify afterwards: no `/Widget` annotations remain and the catalog has no `/AcroForm`. If widgets remain, the file still looks editable in readers even though the content carries the value.
|
|
57
|
+
|
|
58
|
+
## What cannot be filled, and the fix
|
|
59
|
+
|
|
60
|
+
- **A field with no appearance stream and a broken `/DA`**: the reader may refuse to show a new value. Render after filling; if the value is invisible, rebuild that widget's appearance with reportlab and merge it over the page.
|
|
61
|
+
- **A signature field (`/FT /Sig`)**: signing requires a certificate and a signer implementation that this runtime does not ship. Adding text to a signed field invalidates the signature. Tell the user the signature cannot be created here and what would (a certificate plus a signing tool).
|
|
62
|
+
- **A form where the values are drawn as static text, not widgets**: the file is not a form. Inspect with `page.get_text()` — if the labels and lines are plain content and `reader.get_fields()` is empty, treat it as an edit job, not a fill job.
|
|
63
|
+
- **A flat, scanned form**: no field tree exists; filling means drawing text at coordinates (a stamp overlay) and the render decides whether it lines up.
|
|
64
|
+
|
|
65
|
+
## Verify
|
|
66
|
+
|
|
67
|
+
Always: reopen, check `/V`, check `/AP`, then `"<python>" "<cli>" pdf render --input "<file-filled.pdf>" --out-dir "render"` and `read_image` the page. The filled value must be visible inside its widget box, without clipping or overlapping the box border.
|