@cteno/sdk-linux-x64 0.0.0-stage → 2.10.14
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/LICENSE.daemon +31 -0
- package/README.md +4 -2
- package/agents/flow-analyzer/AGENT.md +71 -0
- package/bin/cteno-host-cli +4 -0
- package/bin/cteno-wiki +0 -0
- package/bin/rg +0 -0
- package/bin/secret-exec +0 -0
- package/manifest.json +12 -0
- package/package.json +33 -4
- package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
- package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
- package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
- package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
- package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
- package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
- package/plugins/cteno.inbox/.cteno-plugin/plugin.json +132 -0
- package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
- package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
- package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
- package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
- package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
- package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
- package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
- package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
- package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
- package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
- package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
- package/plugins/cteno.memory/ui/index.html +822 -0
- package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
- package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
- package/plugins/jev/.cteno-plugin/plugin.json +309 -0
- package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
- package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
- package/skills/agent-create/SKILL.md +86 -0
- package/skills/cteno-host-cli/SKILL.md +256 -0
- package/skills/docx/LICENSE +23 -0
- package/skills/docx/NOTICE.md +8 -0
- package/skills/docx/SKILL.md +69 -0
- package/skills/docx/references/cjk_typography.md +357 -0
- package/skills/docx/references/design_principles.md +819 -0
- package/skills/docx/references/revisions-and-comments.md +88 -0
- package/skills/docx/references/track_changes_guide.md +200 -0
- package/skills/docx/references/typography_guide.md +294 -0
- package/skills/docx/scripts/cjk_fonts.py +244 -0
- package/skills/docx/scripts/docx_comments.py +289 -0
- package/skills/docx/scripts/docx_common.py +92 -0
- package/skills/docx/scripts/docx_create.py +259 -0
- package/skills/docx/scripts/docx_edit.py +250 -0
- package/skills/docx/scripts/docx_read.py +149 -0
- package/skills/docx/scripts/docx_revisions.py +146 -0
- package/skills/docx/scripts/docx_template.py +70 -0
- package/skills/docx/scripts/docx_validate.py +156 -0
- package/skills/docx/scripts/render.py +250 -0
- package/skills/docx/tests/test_docx_skill.py +525 -0
- package/skills/home-devices/SKILL.md +121 -0
- package/skills/mcp/SKILL.md +72 -0
- package/skills/mcp/scripts/mcp_setup.py +439 -0
- package/skills/pdf/LICENSE +23 -0
- package/skills/pdf/NOTICE.md +8 -0
- package/skills/pdf/SKILL.md +63 -0
- package/skills/pdf/references/design-tokens.md +381 -0
- package/skills/pdf/references/forms.md +99 -0
- package/skills/pdf/references/nano-pdf-editing.md +40 -0
- package/skills/pdf/references/ocr-extraction.md +165 -0
- package/skills/pdf/scripts/_raster.py +76 -0
- package/skills/pdf/scripts/extract_marker.py +88 -0
- package/skills/pdf/scripts/extract_pymupdf.py +101 -0
- package/skills/pdf/scripts/pdf_create.py +191 -0
- package/skills/pdf/scripts/pdf_fill_form.py +97 -0
- package/skills/pdf/scripts/pdf_form_layout.py +168 -0
- package/skills/pdf/scripts/pdf_make_form.py +145 -0
- package/skills/pdf/scripts/pdf_merge.py +50 -0
- package/skills/pdf/scripts/pdf_meta.py +115 -0
- package/skills/pdf/scripts/pdf_page_image.py +99 -0
- package/skills/pdf/scripts/pdf_read.py +153 -0
- package/skills/pdf/scripts/pdf_secure.py +71 -0
- package/skills/pdf/scripts/pdf_split.py +84 -0
- package/skills/pdf/scripts/pdf_stamp.py +143 -0
- package/skills/pdf/scripts/pdf_watermark.py +51 -0
- package/skills/pdf/scripts/render.py +250 -0
- package/skills/pdf/tests/test_pdf_skill.py +415 -0
- package/skills/pptx/LICENSE +23 -0
- package/skills/pptx/NOTICE.md +8 -0
- package/skills/pptx/SKILL.md +62 -0
- package/skills/pptx/references/design-system.md +392 -0
- package/skills/pptx/references/pptxgenjs.md +420 -0
- package/skills/pptx/references/slide-types.md +413 -0
- package/skills/pptx/scripts/cjk_fonts.py +244 -0
- package/skills/pptx/scripts/pptx_create.py +240 -0
- package/skills/pptx/scripts/pptx_edit.py +436 -0
- package/skills/pptx/scripts/pptx_from_template.py +88 -0
- package/skills/pptx/scripts/pptx_read.py +131 -0
- package/skills/pptx/scripts/pptx_render.py +96 -0
- package/skills/pptx/scripts/render.py +250 -0
- package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
- package/skills/skill-create/SKILL.md +114 -0
- package/skills/video-generation/SKILL.md +27 -0
- package/skills/xlsx/LICENSE +23 -0
- package/skills/xlsx/NOTICE.md +8 -0
- package/skills/xlsx/SKILL.md +63 -0
- package/skills/xlsx/references/format.md +71 -0
- package/skills/xlsx/references/restructuring.md +71 -0
- package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
- package/skills/xlsx/scripts/formula_check.py +428 -0
- package/skills/xlsx/scripts/render.py +250 -0
- package/skills/xlsx/scripts/xlsx_create.py +265 -0
- package/skills/xlsx/scripts/xlsx_edit.py +269 -0
- package/skills/xlsx/scripts/xlsx_read.py +166 -0
- package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
- package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
- package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
- package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
- package/tools/create_goal/TOOL.md +36 -0
- package/tools/image_generation/TOOL.md +81 -0
- package/tools/skill/TOOL.md +117 -0
- package/tools/update_profile_notes/TOOL.md +39 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read a PDF: per-page text, tables, metadata, or form fields. JSON to stdout."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _reconfigure_stdio() -> None:
|
|
13
|
+
for stream in (sys.stdout, sys.stderr):
|
|
14
|
+
try:
|
|
15
|
+
stream.reconfigure(encoding="utf-8")
|
|
16
|
+
except Exception:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _need(module: str, package: str):
|
|
21
|
+
try:
|
|
22
|
+
return __import__(module)
|
|
23
|
+
except ImportError:
|
|
24
|
+
print(f"Missing dependency: install with 'python3 -m pip install {package}'", file=sys.stderr)
|
|
25
|
+
raise SystemExit(2)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_text(path: str, password: str | None) -> dict:
|
|
29
|
+
pdfplumber = _need("pdfplumber", "pdfplumber")
|
|
30
|
+
pages = []
|
|
31
|
+
with pdfplumber.open(path, password=password) as pdf:
|
|
32
|
+
for page in pdf.pages:
|
|
33
|
+
pages.append(page.extract_text() or "")
|
|
34
|
+
return {"page_count": len(pages), "pages": pages}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_tables(path: str, password: str | None, csv_dir: str | None) -> dict:
|
|
38
|
+
pdfplumber = _need("pdfplumber", "pdfplumber")
|
|
39
|
+
result = []
|
|
40
|
+
written = []
|
|
41
|
+
with pdfplumber.open(path, password=password) as pdf:
|
|
42
|
+
for pageno, page in enumerate(pdf.pages, start=1):
|
|
43
|
+
for tidx, table in enumerate(page.extract_tables()):
|
|
44
|
+
result.append({"page": pageno, "index": tidx, "rows": table})
|
|
45
|
+
if csv_dir:
|
|
46
|
+
os.makedirs(csv_dir, exist_ok=True)
|
|
47
|
+
csv_path = os.path.join(csv_dir, f"page{pageno}_table{tidx}.csv")
|
|
48
|
+
with open(csv_path, "w", encoding="utf-8", newline="") as fh:
|
|
49
|
+
csv.writer(fh).writerows([[c if c is not None else "" for c in row] for row in table])
|
|
50
|
+
written.append(csv_path)
|
|
51
|
+
out = {"table_count": len(result), "tables": result}
|
|
52
|
+
if csv_dir:
|
|
53
|
+
out["csv_files"] = written
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_meta(path: str, password: str | None) -> dict:
|
|
58
|
+
pypdf = _need("pypdf", "pypdf")
|
|
59
|
+
reader = pypdf.PdfReader(path)
|
|
60
|
+
encrypted = reader.is_encrypted
|
|
61
|
+
if encrypted:
|
|
62
|
+
if password is None or not reader.decrypt(password):
|
|
63
|
+
return {"encrypted": True, "note": "Provide --password to read metadata of an encrypted file."}
|
|
64
|
+
meta = {}
|
|
65
|
+
if reader.metadata:
|
|
66
|
+
for key, value in reader.metadata.items():
|
|
67
|
+
meta[str(key).lstrip("/")] = str(value)
|
|
68
|
+
pages = []
|
|
69
|
+
for idx, page in enumerate(reader.pages, start=1):
|
|
70
|
+
box = page.mediabox
|
|
71
|
+
pages.append({
|
|
72
|
+
"page": idx,
|
|
73
|
+
"width": float(box.width),
|
|
74
|
+
"height": float(box.height),
|
|
75
|
+
"rotation": int(page.get("/Rotate", 0)),
|
|
76
|
+
})
|
|
77
|
+
# scanned-page heuristic: no extractable text but page has images
|
|
78
|
+
likely_scanned = []
|
|
79
|
+
try:
|
|
80
|
+
pdfplumber = _need("pdfplumber", "pdfplumber")
|
|
81
|
+
with pdfplumber.open(path, password=password) as pdf:
|
|
82
|
+
for pageno, page in enumerate(pdf.pages, start=1):
|
|
83
|
+
text = (page.extract_text() or "").strip()
|
|
84
|
+
if not text and page.images:
|
|
85
|
+
likely_scanned.append(pageno)
|
|
86
|
+
except SystemExit:
|
|
87
|
+
raise
|
|
88
|
+
except Exception as exc: # pragma: no cover - heuristic only
|
|
89
|
+
print(f"Warning: scanned-page check failed: {exc}", file=sys.stderr)
|
|
90
|
+
out = {
|
|
91
|
+
"encrypted": encrypted,
|
|
92
|
+
"page_count": len(reader.pages),
|
|
93
|
+
"metadata": meta,
|
|
94
|
+
"pages": pages,
|
|
95
|
+
"likely_scanned_pages": likely_scanned,
|
|
96
|
+
}
|
|
97
|
+
if likely_scanned:
|
|
98
|
+
out["note"] = ("Image-only pages detected: no text layer to extract. "
|
|
99
|
+
"Use the references/ocr-extraction.md in this skill for OCR.")
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
FIELD_TYPES = {"/Tx": "text", "/Btn": "button", "/Ch": "choice", "/Sig": "signature"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_fields(path: str, password: str | None) -> dict:
|
|
107
|
+
pypdf = _need("pypdf", "pypdf")
|
|
108
|
+
reader = pypdf.PdfReader(path)
|
|
109
|
+
if reader.is_encrypted:
|
|
110
|
+
if password is None or not reader.decrypt(password):
|
|
111
|
+
print("File is encrypted; pass --password.", file=sys.stderr)
|
|
112
|
+
raise SystemExit(3)
|
|
113
|
+
fields = reader.get_fields() or {}
|
|
114
|
+
out = {}
|
|
115
|
+
for name, field in fields.items():
|
|
116
|
+
ftype = FIELD_TYPES.get(str(field.get("/FT")), str(field.get("/FT")))
|
|
117
|
+
value = field.get("/V")
|
|
118
|
+
states = field.get("/_States_")
|
|
119
|
+
entry = {"type": ftype, "value": None if value is None else str(value)}
|
|
120
|
+
if states:
|
|
121
|
+
entry["options"] = [str(s) for s in states]
|
|
122
|
+
out[name] = entry
|
|
123
|
+
return {"field_count": len(out), "fields": out}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> int:
|
|
127
|
+
_reconfigure_stdio()
|
|
128
|
+
parser = argparse.ArgumentParser(description="Extract text, tables, metadata, or form fields from a PDF.")
|
|
129
|
+
parser.add_argument("pdf", help="Input PDF path")
|
|
130
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
131
|
+
mode.add_argument("--text", action="store_true", help="Per-page text as JSON")
|
|
132
|
+
mode.add_argument("--tables", action="store_true", help="Tables as JSON (optionally CSV via --csv-dir)")
|
|
133
|
+
mode.add_argument("--meta", action="store_true", help="Metadata, page sizes, encrypted/scanned flags")
|
|
134
|
+
mode.add_argument("--fields", action="store_true", help="AcroForm fields with types and values")
|
|
135
|
+
parser.add_argument("--csv-dir", help="Also write each table as a CSV file into this directory")
|
|
136
|
+
parser.add_argument("--password", help="Password for encrypted PDFs")
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
|
|
139
|
+
if args.text:
|
|
140
|
+
result = read_text(args.pdf, args.password)
|
|
141
|
+
elif args.tables:
|
|
142
|
+
result = read_tables(args.pdf, args.password, args.csv_dir)
|
|
143
|
+
elif args.meta:
|
|
144
|
+
result = read_meta(args.pdf, args.password)
|
|
145
|
+
else:
|
|
146
|
+
result = read_fields(args.pdf, args.password)
|
|
147
|
+
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
|
148
|
+
print()
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
sys.exit(main())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Encrypt or decrypt a PDF with passwords (AES-256 via pypdf).
|
|
3
|
+
|
|
4
|
+
Note: permission flags set at encryption time are advisory — viewers may honor
|
|
5
|
+
them, but any PDF library can strip them. Only the user password gates content.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> int:
|
|
15
|
+
for stream in (sys.stdout, sys.stderr):
|
|
16
|
+
try:
|
|
17
|
+
stream.reconfigure(encoding="utf-8")
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
parser = argparse.ArgumentParser(description="Encrypt/decrypt PDFs (pypdf, AES-256).")
|
|
21
|
+
parser.add_argument("pdf", help="Input PDF path")
|
|
22
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
23
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
24
|
+
mode.add_argument("--encrypt", action="store_true", help="Encrypt the PDF")
|
|
25
|
+
mode.add_argument("--decrypt", action="store_true", help="Remove encryption (password required)")
|
|
26
|
+
parser.add_argument("--user-password", help="User (open) password for --encrypt")
|
|
27
|
+
parser.add_argument("--owner-password", help="Owner password for --encrypt (defaults to user password)")
|
|
28
|
+
parser.add_argument("--password", help="Known password for --decrypt")
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from pypdf import PdfReader, PdfWriter
|
|
33
|
+
except ImportError:
|
|
34
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
reader = PdfReader(args.pdf)
|
|
38
|
+
if args.encrypt:
|
|
39
|
+
if not args.user_password:
|
|
40
|
+
print("Error: --encrypt requires --user-password", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
if reader.is_encrypted:
|
|
43
|
+
print("Error: input already encrypted; decrypt first", file=sys.stderr)
|
|
44
|
+
return 3
|
|
45
|
+
writer = PdfWriter()
|
|
46
|
+
writer.append(reader)
|
|
47
|
+
writer.encrypt(
|
|
48
|
+
user_password=args.user_password,
|
|
49
|
+
owner_password=args.owner_password or args.user_password,
|
|
50
|
+
algorithm="AES-256",
|
|
51
|
+
)
|
|
52
|
+
action = "encrypted"
|
|
53
|
+
else:
|
|
54
|
+
if not reader.is_encrypted:
|
|
55
|
+
print("Error: input is not encrypted", file=sys.stderr)
|
|
56
|
+
return 3
|
|
57
|
+
if args.password is None or not reader.decrypt(args.password):
|
|
58
|
+
print("Error: wrong or missing --password", file=sys.stderr)
|
|
59
|
+
return 4
|
|
60
|
+
writer = PdfWriter()
|
|
61
|
+
writer.append(reader)
|
|
62
|
+
action = "decrypted"
|
|
63
|
+
|
|
64
|
+
with open(args.output, "wb") as fh:
|
|
65
|
+
writer.write(fh)
|
|
66
|
+
print(json.dumps({"output": args.output, "action": action, "page_count": len(reader.pages)}))
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
sys.exit(main())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract page ranges from a PDF, optionally rotating and/or compressing pages."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_pages(spec: str, page_count: int) -> list[int]:
|
|
11
|
+
"""Parse a 1-based page spec like '1-3,5,9-' into 0-based indices."""
|
|
12
|
+
indices: list[int] = []
|
|
13
|
+
for part in spec.split(","):
|
|
14
|
+
part = part.strip()
|
|
15
|
+
if not part:
|
|
16
|
+
continue
|
|
17
|
+
if "-" in part:
|
|
18
|
+
start_s, _, end_s = part.partition("-")
|
|
19
|
+
start = int(start_s) if start_s else 1
|
|
20
|
+
end = int(end_s) if end_s else page_count
|
|
21
|
+
else:
|
|
22
|
+
start = end = int(part)
|
|
23
|
+
if start < 1 or end > page_count or start > end:
|
|
24
|
+
raise ValueError(f"Page range {part!r} out of bounds (1-{page_count})")
|
|
25
|
+
indices.extend(range(start - 1, end))
|
|
26
|
+
return indices
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> int:
|
|
30
|
+
for stream in (sys.stdout, sys.stderr):
|
|
31
|
+
try:
|
|
32
|
+
stream.reconfigure(encoding="utf-8")
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
description="Split/extract pages from a PDF (pypdf). Pages are 1-based: '1-3,5,9-'.")
|
|
37
|
+
parser.add_argument("pdf", help="Input PDF path")
|
|
38
|
+
parser.add_argument("--pages", required=True, help="1-based page spec, e.g. '1-3,5,9-'")
|
|
39
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
40
|
+
parser.add_argument("--rotate", type=int, default=0,
|
|
41
|
+
help="Rotate extracted pages clockwise (multiple of 90)")
|
|
42
|
+
parser.add_argument("--compress", action="store_true",
|
|
43
|
+
help="Deflate content streams (modest savings; does not recompress images)")
|
|
44
|
+
parser.add_argument("--password", help="Password if the input is encrypted")
|
|
45
|
+
args = parser.parse_args()
|
|
46
|
+
|
|
47
|
+
if args.rotate % 90 != 0:
|
|
48
|
+
print("Error: --rotate must be a multiple of 90", file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from pypdf import PdfReader, PdfWriter
|
|
53
|
+
except ImportError:
|
|
54
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
reader = PdfReader(args.pdf)
|
|
58
|
+
if reader.is_encrypted:
|
|
59
|
+
if args.password is None or not reader.decrypt(args.password):
|
|
60
|
+
print("Error: input is encrypted; pass --password", file=sys.stderr)
|
|
61
|
+
return 3
|
|
62
|
+
try:
|
|
63
|
+
indices = parse_pages(args.pages, len(reader.pages))
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
|
|
68
|
+
writer = PdfWriter()
|
|
69
|
+
for idx in indices:
|
|
70
|
+
page = reader.pages[idx]
|
|
71
|
+
if args.rotate:
|
|
72
|
+
page.rotate(args.rotate)
|
|
73
|
+
writer.add_page(page)
|
|
74
|
+
if args.compress:
|
|
75
|
+
for page in writer.pages:
|
|
76
|
+
page.compress_content_streams()
|
|
77
|
+
with open(args.output, "wb") as fh:
|
|
78
|
+
writer.write(fh)
|
|
79
|
+
print(json.dumps({"output": args.output, "page_count": len(indices), "rotated": args.rotate}))
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stamp text or an image at coordinates onto selected PDF pages.
|
|
3
|
+
|
|
4
|
+
Builds an in-memory single-page overlay with reportlab, then merges it
|
|
5
|
+
onto each selected page with pypdf. Coordinates are PDF points, origin
|
|
6
|
+
bottom-left. Covers 'sign here' arrows, diagonal DRAFT banners, and
|
|
7
|
+
page-corner labels.
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
pdf_stamp.py in.pdf -o out.pdf --text "DRAFT" --x 200 --y 400 \
|
|
11
|
+
--font-size 60 --rotation 45 --opacity 0.3 --color "#cc0000"
|
|
12
|
+
pdf_stamp.py in.pdf -o out.pdf --image sig.png --x 400 --y 60 \
|
|
13
|
+
--width 120 --pages 3
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_pages(spec: str, page_count: int) -> list[int]:
|
|
24
|
+
pages: set[int] = set()
|
|
25
|
+
for part in spec.split(","):
|
|
26
|
+
part = part.strip()
|
|
27
|
+
if not part:
|
|
28
|
+
continue
|
|
29
|
+
if "-" in part:
|
|
30
|
+
start_s, _, end_s = part.partition("-")
|
|
31
|
+
start = int(start_s) if start_s else 1
|
|
32
|
+
end = int(end_s) if end_s else page_count
|
|
33
|
+
pages.update(range(start, end + 1))
|
|
34
|
+
else:
|
|
35
|
+
pages.add(int(part))
|
|
36
|
+
bad = [p for p in pages if not 1 <= p <= page_count]
|
|
37
|
+
if bad:
|
|
38
|
+
raise ValueError(f"pages out of range 1-{page_count}: {sorted(bad)}")
|
|
39
|
+
return sorted(pages)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_overlay(args, page_width: float, page_height: float) -> bytes:
|
|
43
|
+
from reportlab.lib.colors import HexColor
|
|
44
|
+
from reportlab.pdfgen import canvas
|
|
45
|
+
buf = io.BytesIO()
|
|
46
|
+
c = canvas.Canvas(buf, pagesize=(page_width, page_height))
|
|
47
|
+
c.saveState()
|
|
48
|
+
try:
|
|
49
|
+
c.setFillAlpha(float(args.opacity))
|
|
50
|
+
c.setStrokeAlpha(float(args.opacity))
|
|
51
|
+
except Exception:
|
|
52
|
+
pass # very old reportlab: no alpha support
|
|
53
|
+
c.translate(float(args.x), float(args.y))
|
|
54
|
+
if args.rotation:
|
|
55
|
+
c.rotate(float(args.rotation))
|
|
56
|
+
if args.text:
|
|
57
|
+
c.setFont(args.font, float(args.font_size))
|
|
58
|
+
c.setFillColor(HexColor(args.color))
|
|
59
|
+
c.drawString(0, 0, args.text)
|
|
60
|
+
else:
|
|
61
|
+
kwargs = {}
|
|
62
|
+
if args.width:
|
|
63
|
+
kwargs["width"] = float(args.width)
|
|
64
|
+
if args.height:
|
|
65
|
+
kwargs["height"] = float(args.height)
|
|
66
|
+
if "width" in kwargs and "height" not in kwargs:
|
|
67
|
+
from PIL import Image as PILImage
|
|
68
|
+
with PILImage.open(args.image) as im:
|
|
69
|
+
kwargs["height"] = kwargs["width"] * im.height / im.width
|
|
70
|
+
c.drawImage(args.image, 0, 0, mask="auto", **kwargs)
|
|
71
|
+
c.restoreState()
|
|
72
|
+
c.save()
|
|
73
|
+
return buf.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> int:
|
|
77
|
+
for stream in (sys.stdout, sys.stderr):
|
|
78
|
+
try:
|
|
79
|
+
stream.reconfigure(encoding="utf-8")
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
parser = argparse.ArgumentParser(description="Stamp text or an image onto PDF pages.")
|
|
83
|
+
parser.add_argument("pdf", help="Input PDF path")
|
|
84
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
85
|
+
what = parser.add_mutually_exclusive_group(required=True)
|
|
86
|
+
what.add_argument("--text", help="Text to stamp")
|
|
87
|
+
what.add_argument("--image", help="Image file to stamp (PNG/JPEG)")
|
|
88
|
+
parser.add_argument("--x", type=float, required=True, help="X in points (origin bottom-left)")
|
|
89
|
+
parser.add_argument("--y", type=float, required=True, help="Y in points")
|
|
90
|
+
parser.add_argument("--pages", default="1-", help="1-based ranges, e.g. '1-3,5' (default: all)")
|
|
91
|
+
parser.add_argument("--font", default="Helvetica", help="Font name (default Helvetica)")
|
|
92
|
+
parser.add_argument("--font-size", type=float, default=24, help="Font size in points")
|
|
93
|
+
parser.add_argument("--color", default="#000000", help="Text color as #RRGGBB")
|
|
94
|
+
parser.add_argument("--rotation", type=float, default=0, help="Degrees counterclockwise")
|
|
95
|
+
parser.add_argument("--opacity", type=float, default=1.0, help="0.0-1.0 (default 1.0)")
|
|
96
|
+
parser.add_argument("--width", type=float, help="Image width in points")
|
|
97
|
+
parser.add_argument("--height", type=float, help="Image height in points")
|
|
98
|
+
parser.add_argument("--under", action="store_true",
|
|
99
|
+
help="Place the stamp under existing content instead of over it")
|
|
100
|
+
parser.add_argument("--password", help="Password if the input is encrypted")
|
|
101
|
+
args = parser.parse_args()
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
from pypdf import PdfReader, PdfWriter
|
|
105
|
+
except ImportError:
|
|
106
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf reportlab'",
|
|
107
|
+
file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
reader = PdfReader(args.pdf)
|
|
111
|
+
if reader.is_encrypted:
|
|
112
|
+
if args.password is None or not reader.decrypt(args.password):
|
|
113
|
+
print("Error: input is encrypted; pass --password", file=sys.stderr)
|
|
114
|
+
return 3
|
|
115
|
+
try:
|
|
116
|
+
pages = set(parse_pages(args.pages, len(reader.pages)))
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
119
|
+
return 4
|
|
120
|
+
|
|
121
|
+
writer = PdfWriter()
|
|
122
|
+
overlay_cache: dict[tuple[float, float], object] = {}
|
|
123
|
+
for idx, page in enumerate(reader.pages, start=1):
|
|
124
|
+
if idx in pages:
|
|
125
|
+
size = (float(page.mediabox.width), float(page.mediabox.height))
|
|
126
|
+
if size not in overlay_cache:
|
|
127
|
+
overlay_pdf = PdfReader(io.BytesIO(build_overlay(args, *size)))
|
|
128
|
+
overlay_cache[size] = overlay_pdf.pages[0]
|
|
129
|
+
stamp = overlay_cache[size]
|
|
130
|
+
if args.under:
|
|
131
|
+
page.merge_page(stamp, over=False)
|
|
132
|
+
else:
|
|
133
|
+
page.merge_page(stamp)
|
|
134
|
+
writer.add_page(page)
|
|
135
|
+
with open(args.output, "wb") as fh:
|
|
136
|
+
writer.write(fh)
|
|
137
|
+
print(json.dumps({"output": args.output, "stamped_pages": sorted(pages),
|
|
138
|
+
"kind": "text" if args.text else "image"}, ensure_ascii=False))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stamp/watermark every page of a PDF with page 1 of another PDF."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> int:
|
|
11
|
+
for stream in (sys.stdout, sys.stderr):
|
|
12
|
+
try:
|
|
13
|
+
stream.reconfigure(encoding="utf-8")
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
description="Overlay (stamp) or underlay (watermark) a one-page PDF onto every page.")
|
|
18
|
+
parser.add_argument("pdf", help="Input PDF path")
|
|
19
|
+
parser.add_argument("--stamp", required=True, help="One-page PDF to apply (page 1 is used)")
|
|
20
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
21
|
+
parser.add_argument("--under", action="store_true",
|
|
22
|
+
help="Place stamp under the page content (background watermark)")
|
|
23
|
+
parser.add_argument("--password", help="Password if the input is encrypted")
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from pypdf import PdfReader, PdfWriter
|
|
28
|
+
except ImportError:
|
|
29
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
|
|
30
|
+
return 2
|
|
31
|
+
|
|
32
|
+
reader = PdfReader(args.pdf)
|
|
33
|
+
if reader.is_encrypted:
|
|
34
|
+
if args.password is None or not reader.decrypt(args.password):
|
|
35
|
+
print("Error: input is encrypted; pass --password", file=sys.stderr)
|
|
36
|
+
return 3
|
|
37
|
+
stamp_page = PdfReader(args.stamp).pages[0]
|
|
38
|
+
|
|
39
|
+
writer = PdfWriter()
|
|
40
|
+
writer.append(reader)
|
|
41
|
+
for page in writer.pages:
|
|
42
|
+
page.merge_page(stamp_page, over=not args.under)
|
|
43
|
+
with open(args.output, "wb") as fh:
|
|
44
|
+
writer.write(fh)
|
|
45
|
+
print(json.dumps({"output": args.output, "page_count": len(writer.pages),
|
|
46
|
+
"mode": "under" if args.under else "over"}))
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
sys.exit(main())
|