@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,76 @@
|
|
|
1
|
+
"""Shared page rasterizer with a fallback chain: pypdfium2 -> pdftoppm.
|
|
2
|
+
|
|
3
|
+
Returns PIL Images so callers can annotate/save. Not a CLI.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def available_backends() -> list[str]:
|
|
14
|
+
"""Names of usable rasterizer backends, in preference order."""
|
|
15
|
+
backends = []
|
|
16
|
+
try:
|
|
17
|
+
import pypdfium2
|
|
18
|
+
backends.append("pypdfium2")
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
|
21
|
+
if shutil.which("pdftoppm"):
|
|
22
|
+
backends.append("pdftoppm")
|
|
23
|
+
return backends
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def missing_hints() -> list[str]:
|
|
27
|
+
"""Install hints for when no backend is available."""
|
|
28
|
+
return [
|
|
29
|
+
"python3 -m pip install pypdfium2",
|
|
30
|
+
"poppler-utils (provides pdftoppm), e.g. apt-get install poppler-utils",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rasterize_page(pdf_path: str, page: int, dpi: int = 150, password: str | None = None):
|
|
35
|
+
"""Render one 1-based page to a PIL Image, or None if no backend works.
|
|
36
|
+
|
|
37
|
+
Raises ValueError for an out-of-range page when a backend is present.
|
|
38
|
+
"""
|
|
39
|
+
for backend in available_backends():
|
|
40
|
+
if backend == "pypdfium2":
|
|
41
|
+
return _via_pdfium(pdf_path, page, dpi, password)
|
|
42
|
+
if backend == "pdftoppm":
|
|
43
|
+
img = _via_pdftoppm(pdf_path, page, dpi, password)
|
|
44
|
+
if img is not None:
|
|
45
|
+
return img
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _via_pdfium(pdf_path: str, page: int, dpi: int, password: str | None):
|
|
50
|
+
import pypdfium2 as pdfium
|
|
51
|
+
doc = pdfium.PdfDocument(pdf_path, password=password)
|
|
52
|
+
try:
|
|
53
|
+
if not 1 <= page <= len(doc):
|
|
54
|
+
raise ValueError(f"page {page} out of range 1-{len(doc)}")
|
|
55
|
+
bitmap = doc[page - 1].render(scale=dpi / 72.0)
|
|
56
|
+
return bitmap.to_pil().convert("RGB")
|
|
57
|
+
finally:
|
|
58
|
+
doc.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _via_pdftoppm(pdf_path: str, page: int, dpi: int, password: str | None):
|
|
62
|
+
from PIL import Image
|
|
63
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
64
|
+
prefix = str(Path(tmp) / "page")
|
|
65
|
+
cmd = ["pdftoppm", "-png", "-r", str(dpi), "-f", str(page), "-l", str(page)]
|
|
66
|
+
if password:
|
|
67
|
+
cmd += ["-upw", password]
|
|
68
|
+
cmd += [pdf_path, prefix]
|
|
69
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False)
|
|
70
|
+
if proc.returncode != 0:
|
|
71
|
+
raise ValueError(f"pdftoppm failed: {proc.stderr.strip()}")
|
|
72
|
+
produced = sorted(Path(tmp).glob("page*.png"))
|
|
73
|
+
if not produced:
|
|
74
|
+
raise ValueError(f"page {page} out of range (pdftoppm produced no image)")
|
|
75
|
+
with Image.open(produced[0]) as img:
|
|
76
|
+
return img.convert("RGB")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract text from documents using marker-pdf. High-quality OCR + layout analysis.
|
|
3
|
+
|
|
4
|
+
Requires ~3-5GB disk (PyTorch + models downloaded on first use).
|
|
5
|
+
Supports: PDF, DOCX, PPTX, XLSX, HTML, EPUB, images.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python extract_marker.py document.pdf
|
|
9
|
+
python extract_marker.py document.pdf --output_dir ./output
|
|
10
|
+
python extract_marker.py presentation.pptx
|
|
11
|
+
python extract_marker.py spreadsheet.xlsx
|
|
12
|
+
python extract_marker.py scanned_doc.pdf # OCR works here
|
|
13
|
+
python extract_marker.py document.pdf --json # Structured output
|
|
14
|
+
python extract_marker.py document.pdf --use_llm # LLM-boosted accuracy
|
|
15
|
+
"""
|
|
16
|
+
import sys
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
def convert(path, output_dir=None, output_format="markdown", use_llm=False):
|
|
20
|
+
from marker.converters.pdf import PdfConverter
|
|
21
|
+
from marker.models import create_model_dict
|
|
22
|
+
from marker.config.parser import ConfigParser
|
|
23
|
+
|
|
24
|
+
config_dict = {}
|
|
25
|
+
if use_llm:
|
|
26
|
+
config_dict["use_llm"] = True
|
|
27
|
+
|
|
28
|
+
config_parser = ConfigParser(config_dict)
|
|
29
|
+
models = create_model_dict()
|
|
30
|
+
converter = PdfConverter(config=config_parser.generate_config_dict(), artifact_dict=models)
|
|
31
|
+
rendered = converter(path)
|
|
32
|
+
|
|
33
|
+
if output_format == "json":
|
|
34
|
+
import json
|
|
35
|
+
print(json.dumps({
|
|
36
|
+
"markdown": rendered.markdown,
|
|
37
|
+
"metadata": rendered.metadata if hasattr(rendered, "metadata") else {},
|
|
38
|
+
}, indent=2, ensure_ascii=False))
|
|
39
|
+
else:
|
|
40
|
+
print(rendered.markdown)
|
|
41
|
+
|
|
42
|
+
# Save images if output_dir specified
|
|
43
|
+
if output_dir and hasattr(rendered, "images") and rendered.images:
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
46
|
+
for name, img_data in rendered.images.items():
|
|
47
|
+
img_path = os.path.join(output_dir, name)
|
|
48
|
+
with open(img_path, "wb") as f:
|
|
49
|
+
f.write(img_data)
|
|
50
|
+
print(f"\nSaved {len(rendered.images)} image(s) to {output_dir}/", file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_requirements():
|
|
54
|
+
"""Check disk space before installing."""
|
|
55
|
+
import shutil
|
|
56
|
+
free_gb = shutil.disk_usage("/").free / (1024**3)
|
|
57
|
+
if free_gb < 5:
|
|
58
|
+
print(f"⚠️ Only {free_gb:.1f}GB free. marker-pdf needs ~5GB for PyTorch + models.")
|
|
59
|
+
print("Use pymupdf instead (scripts/extract_pymupdf.py) or free up disk space.")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
print(f"✓ {free_gb:.1f}GB free — sufficient for marker-pdf")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
import argparse
|
|
66
|
+
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
description="Extract text from documents using marker-pdf (high-quality OCR + layout analysis)."
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument("path", nargs="?", help="Document to convert (PDF, DOCX, PPTX, XLSX, HTML, EPUB, image)")
|
|
71
|
+
parser.add_argument("--output_dir", help="Directory to save extracted images")
|
|
72
|
+
parser.add_argument("--json", action="store_true", help="Structured JSON output instead of markdown")
|
|
73
|
+
parser.add_argument("--use_llm", action="store_true", help="LLM-boosted accuracy")
|
|
74
|
+
parser.add_argument("--check", action="store_true", help="Check disk space requirements and exit")
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
if args.check:
|
|
78
|
+
check_requirements()
|
|
79
|
+
sys.exit(0)
|
|
80
|
+
if not args.path:
|
|
81
|
+
parser.error("path is required unless --check is given")
|
|
82
|
+
|
|
83
|
+
convert(
|
|
84
|
+
args.path,
|
|
85
|
+
output_dir=args.output_dir,
|
|
86
|
+
output_format="json" if args.json else "markdown",
|
|
87
|
+
use_llm=args.use_llm,
|
|
88
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract text from documents using pymupdf. Lightweight (~25MB), no models.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python extract_pymupdf.py document.pdf
|
|
6
|
+
python extract_pymupdf.py document.pdf --markdown
|
|
7
|
+
python extract_pymupdf.py document.pdf --pages 0-4
|
|
8
|
+
python extract_pymupdf.py document.pdf --images output_dir/
|
|
9
|
+
python extract_pymupdf.py document.pdf --tables
|
|
10
|
+
python extract_pymupdf.py document.pdf --metadata
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
def extract_text(path, pages=None):
|
|
16
|
+
import pymupdf
|
|
17
|
+
doc = pymupdf.open(path)
|
|
18
|
+
page_range = range(len(doc)) if pages is None else pages
|
|
19
|
+
for i in page_range:
|
|
20
|
+
if i < len(doc):
|
|
21
|
+
print(f"\n--- Page {i+1}/{len(doc)} ---\n")
|
|
22
|
+
print(doc[i].get_text())
|
|
23
|
+
|
|
24
|
+
def extract_markdown(path, pages=None):
|
|
25
|
+
import pymupdf4llm
|
|
26
|
+
md = pymupdf4llm.to_markdown(path, pages=pages)
|
|
27
|
+
print(md)
|
|
28
|
+
|
|
29
|
+
def extract_tables(path):
|
|
30
|
+
import pymupdf
|
|
31
|
+
doc = pymupdf.open(path)
|
|
32
|
+
for i, page in enumerate(doc):
|
|
33
|
+
tables = page.find_tables()
|
|
34
|
+
for j, table in enumerate(tables.tables):
|
|
35
|
+
print(f"\n--- Page {i+1}, Table {j+1} ---\n")
|
|
36
|
+
df = table.to_pandas()
|
|
37
|
+
print(df.to_markdown(index=False))
|
|
38
|
+
|
|
39
|
+
def extract_images(path, output_dir):
|
|
40
|
+
import pymupdf
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
43
|
+
doc = pymupdf.open(path)
|
|
44
|
+
count = 0
|
|
45
|
+
for i, page in enumerate(doc):
|
|
46
|
+
for img_idx, img in enumerate(page.get_images(full=True)):
|
|
47
|
+
xref = img[0]
|
|
48
|
+
pix = pymupdf.Pixmap(doc, xref)
|
|
49
|
+
if pix.n >= 5:
|
|
50
|
+
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
|
|
51
|
+
out_path = f"{output_dir}/page{i+1}_img{img_idx+1}.png"
|
|
52
|
+
pix.save(out_path)
|
|
53
|
+
count += 1
|
|
54
|
+
print(f"Extracted {count} images to {output_dir}/")
|
|
55
|
+
|
|
56
|
+
def show_metadata(path):
|
|
57
|
+
import pymupdf
|
|
58
|
+
doc = pymupdf.open(path)
|
|
59
|
+
print(json.dumps({
|
|
60
|
+
"pages": len(doc),
|
|
61
|
+
"title": doc.metadata.get("title", ""),
|
|
62
|
+
"author": doc.metadata.get("author", ""),
|
|
63
|
+
"subject": doc.metadata.get("subject", ""),
|
|
64
|
+
"creator": doc.metadata.get("creator", ""),
|
|
65
|
+
"producer": doc.metadata.get("producer", ""),
|
|
66
|
+
"format": doc.metadata.get("format", ""),
|
|
67
|
+
}, indent=2))
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
import argparse
|
|
71
|
+
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
description="Extract text/tables/images/metadata from documents using pymupdf (lightweight, no models)."
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("path", help="Document to read")
|
|
76
|
+
parser.add_argument("--pages", help="Page selection: N or START-END (0-indexed)")
|
|
77
|
+
parser.add_argument("--markdown", action="store_true", help="Markdown output via pymupdf4llm")
|
|
78
|
+
parser.add_argument("--tables", action="store_true", help="Extract tables as markdown")
|
|
79
|
+
parser.add_argument("--images", nargs="?", const="./images", metavar="OUTPUT_DIR",
|
|
80
|
+
help="Extract embedded images to OUTPUT_DIR (default ./images)")
|
|
81
|
+
parser.add_argument("--metadata", action="store_true", help="Show document metadata as JSON")
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
|
|
84
|
+
pages = None
|
|
85
|
+
if args.pages:
|
|
86
|
+
if "-" in args.pages:
|
|
87
|
+
start, end = args.pages.split("-")
|
|
88
|
+
pages = list(range(int(start), int(end) + 1))
|
|
89
|
+
else:
|
|
90
|
+
pages = [int(args.pages)]
|
|
91
|
+
|
|
92
|
+
if args.metadata:
|
|
93
|
+
show_metadata(args.path)
|
|
94
|
+
elif args.tables:
|
|
95
|
+
extract_tables(args.path)
|
|
96
|
+
elif args.images is not None:
|
|
97
|
+
extract_images(args.path, args.images)
|
|
98
|
+
elif args.markdown:
|
|
99
|
+
extract_markdown(args.path, pages=pages)
|
|
100
|
+
else:
|
|
101
|
+
extract_text(args.path, pages=pages)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a PDF from a JSON spec using reportlab platypus.
|
|
3
|
+
|
|
4
|
+
Spec format (UTF-8 JSON):
|
|
5
|
+
{
|
|
6
|
+
"title": "Example Report",
|
|
7
|
+
"author": "example-author",
|
|
8
|
+
"page_size": "A4", // or "letter" (default: A4)
|
|
9
|
+
"page_numbers": true, // default true
|
|
10
|
+
"font": "auto", // auto (default): CJK text gets a CJK font automatically;
|
|
11
|
+
// or a path to a .ttf/.otf/.ttc, or "STSong-Light" (built-in CID font)
|
|
12
|
+
"elements": [
|
|
13
|
+
{"type": "heading", "text": "Section 1", "level": 1},
|
|
14
|
+
{"type": "paragraph", "text": "Body text..."},
|
|
15
|
+
{"type": "table", "rows": [["H1", "H2"], ["a", "b"]], "header": true},
|
|
16
|
+
{"type": "image", "path": "chart.png", "width": 400},
|
|
17
|
+
{"type": "pagebreak"}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _reconfigure_stdio() -> None:
|
|
29
|
+
for stream in (sys.stdout, sys.stderr):
|
|
30
|
+
try:
|
|
31
|
+
stream.reconfigure(encoding="utf-8")
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _has_cjk(spec: dict) -> bool:
|
|
37
|
+
import re
|
|
38
|
+
pat = re.compile(r"[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uff00-\uffef]")
|
|
39
|
+
return bool(pat.search(json.dumps(spec, ensure_ascii=False)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
SYSTEM_CJK_FONTS = [
|
|
43
|
+
# (path, subfont index) - first one that exists wins. Fonts are embedded (subset) in the PDF.
|
|
44
|
+
# reportlab needs TrueType outlines (glyf): Hiragino (CFF) cannot be embedded
|
|
45
|
+
("/System/Library/Fonts/STHeiti Medium.ttc", 1), # Heiti SC Medium
|
|
46
|
+
("/System/Library/Fonts/Supplemental/Songti.ttc", 6), # Songti SC Regular
|
|
47
|
+
("C:/Windows/Fonts/msyh.ttc", 0),
|
|
48
|
+
("C:/Windows/Fonts/simhei.ttf", 0),
|
|
49
|
+
("C:/Windows/Fonts/simsun.ttc", 0),
|
|
50
|
+
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2),
|
|
51
|
+
("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 2),
|
|
52
|
+
("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_font(spec: dict):
|
|
57
|
+
"""Return (regular, bold) font names to use; registers a CJK font when needed."""
|
|
58
|
+
from reportlab.pdfbase import pdfmetrics
|
|
59
|
+
choice = spec.get("font", "auto")
|
|
60
|
+
if choice == "auto" and not _has_cjk(spec):
|
|
61
|
+
return "Helvetica", "Helvetica-Bold"
|
|
62
|
+
if choice in ("auto", None):
|
|
63
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
64
|
+
import os
|
|
65
|
+
for path, idx in SYSTEM_CJK_FONTS:
|
|
66
|
+
if os.path.exists(path):
|
|
67
|
+
try:
|
|
68
|
+
pdfmetrics.registerFont(TTFont("CJK", path, subfontIndex=idx))
|
|
69
|
+
return "CJK", "CJK"
|
|
70
|
+
except Exception:
|
|
71
|
+
continue
|
|
72
|
+
choice = "STSong-Light" # built-in Adobe CID font: always works, not embedded
|
|
73
|
+
if choice in ("STSong-Light", "MSung-Light", "HeiseiMin-W3", "HYSMyeongJo-Medium"):
|
|
74
|
+
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
|
75
|
+
pdfmetrics.registerFont(UnicodeCIDFont(choice))
|
|
76
|
+
return choice, choice
|
|
77
|
+
if str(choice).lower().endswith((".ttf", ".otf", ".ttc")):
|
|
78
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
79
|
+
pdfmetrics.registerFont(TTFont("UserFont", choice))
|
|
80
|
+
return "UserFont", "UserFont"
|
|
81
|
+
return choice, choice
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_pdf(spec: dict, out_path: str) -> int:
|
|
85
|
+
try:
|
|
86
|
+
from reportlab.lib import colors
|
|
87
|
+
from reportlab.lib.pagesizes import A4, letter
|
|
88
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
89
|
+
from reportlab.lib.units import inch
|
|
90
|
+
from reportlab.platypus import (
|
|
91
|
+
Image,
|
|
92
|
+
PageBreak,
|
|
93
|
+
Paragraph,
|
|
94
|
+
SimpleDocTemplate,
|
|
95
|
+
Spacer,
|
|
96
|
+
Table,
|
|
97
|
+
TableStyle,
|
|
98
|
+
)
|
|
99
|
+
except ImportError:
|
|
100
|
+
print("Missing dependency: install with 'python3 -m pip install reportlab'", file=sys.stderr)
|
|
101
|
+
return 2
|
|
102
|
+
|
|
103
|
+
page_size = letter if str(spec.get("page_size", "A4")).lower() == "letter" else A4
|
|
104
|
+
styles = getSampleStyleSheet()
|
|
105
|
+
regular, bold = register_font(spec)
|
|
106
|
+
if regular != "Helvetica":
|
|
107
|
+
from reportlab.lib.styles import ParagraphStyle
|
|
108
|
+
for name in ("BodyText", "Heading1", "Heading2", "Heading3", "Title"):
|
|
109
|
+
st = styles[name]
|
|
110
|
+
st.fontName = bold if name != "BodyText" else regular
|
|
111
|
+
st.wordWrap = "CJK" # break lines between CJK characters
|
|
112
|
+
styles["BodyText"].leading = styles["BodyText"].fontSize * 1.6
|
|
113
|
+
story = []
|
|
114
|
+
for el in spec.get("elements", []):
|
|
115
|
+
etype = el.get("type")
|
|
116
|
+
if etype == "heading":
|
|
117
|
+
level = min(max(int(el.get("level", 1)), 1), 3)
|
|
118
|
+
story.append(Paragraph(el.get("text", ""), styles[f"Heading{level}"]))
|
|
119
|
+
elif etype == "paragraph":
|
|
120
|
+
story.append(Paragraph(el.get("text", ""), styles["BodyText"]))
|
|
121
|
+
story.append(Spacer(1, 6))
|
|
122
|
+
elif etype == "table":
|
|
123
|
+
rows = el.get("rows", [])
|
|
124
|
+
if not rows:
|
|
125
|
+
continue
|
|
126
|
+
body_style = styles["BodyText"]
|
|
127
|
+
rows = [[Paragraph(str(c), body_style) for c in r] for r in rows] # cells wrap instead of overflowing
|
|
128
|
+
table = Table(rows, repeatRows=1 if el.get("header", True) else 0)
|
|
129
|
+
style = [
|
|
130
|
+
("FONTNAME", (0, 0), (-1, -1), regular),
|
|
131
|
+
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
|
132
|
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
133
|
+
]
|
|
134
|
+
if el.get("header", True):
|
|
135
|
+
style += [
|
|
136
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
|
137
|
+
("FONTNAME", (0, 0), (-1, 0), bold),
|
|
138
|
+
]
|
|
139
|
+
table.setStyle(TableStyle(style))
|
|
140
|
+
story.append(table)
|
|
141
|
+
story.append(Spacer(1, 10))
|
|
142
|
+
elif etype == "image":
|
|
143
|
+
kwargs = {}
|
|
144
|
+
if el.get("width"):
|
|
145
|
+
kwargs["width"] = float(el["width"])
|
|
146
|
+
if el.get("height"):
|
|
147
|
+
kwargs["height"] = float(el["height"])
|
|
148
|
+
img = Image(el["path"], **kwargs)
|
|
149
|
+
if "width" in kwargs and "height" not in kwargs:
|
|
150
|
+
# keep aspect ratio
|
|
151
|
+
ratio = img.imageHeight / img.imageWidth
|
|
152
|
+
img.drawWidth = kwargs["width"]
|
|
153
|
+
img.drawHeight = kwargs["width"] * ratio
|
|
154
|
+
story.append(img)
|
|
155
|
+
story.append(Spacer(1, 10))
|
|
156
|
+
elif etype == "pagebreak":
|
|
157
|
+
story.append(PageBreak())
|
|
158
|
+
else:
|
|
159
|
+
print(f"Warning: unknown element type {etype!r}, skipped", file=sys.stderr)
|
|
160
|
+
|
|
161
|
+
def draw_page_number(canvas, doc):
|
|
162
|
+
if spec.get("page_numbers", True):
|
|
163
|
+
canvas.saveState()
|
|
164
|
+
canvas.setFont(regular, 9)
|
|
165
|
+
canvas.drawCentredString(page_size[0] / 2.0, 0.5 * inch, f"Page {doc.page}")
|
|
166
|
+
canvas.restoreState()
|
|
167
|
+
|
|
168
|
+
doc = SimpleDocTemplate(
|
|
169
|
+
out_path,
|
|
170
|
+
pagesize=page_size,
|
|
171
|
+
title=spec.get("title", ""),
|
|
172
|
+
author=spec.get("author", ""),
|
|
173
|
+
)
|
|
174
|
+
doc.build(story, onFirstPage=draw_page_number, onLaterPages=draw_page_number)
|
|
175
|
+
print(json.dumps({"output": out_path, "elements": len(spec.get("elements", []))}))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> int:
|
|
180
|
+
_reconfigure_stdio()
|
|
181
|
+
parser = argparse.ArgumentParser(description="Create a PDF from a JSON spec (reportlab).")
|
|
182
|
+
parser.add_argument("spec", help="Path to UTF-8 JSON spec file")
|
|
183
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
184
|
+
args = parser.parse_args()
|
|
185
|
+
with open(args.spec, encoding="utf-8") as fh:
|
|
186
|
+
spec = json.load(fh)
|
|
187
|
+
return build_pdf(spec, args.output)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
sys.exit(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fill AcroForm fields from a UTF-8 JSON file; optionally flatten.
|
|
3
|
+
|
|
4
|
+
The JSON is a flat object: {"FieldName": "value", "Agree": true, ...}
|
|
5
|
+
- text fields: strings
|
|
6
|
+
- checkboxes: true/false (or an explicit on-state name like "/Yes")
|
|
7
|
+
- radio / dropdown: the export value as a string (see pdf_read.py --fields "options")
|
|
8
|
+
|
|
9
|
+
Sets NeedAppearances so conforming viewers regenerate field appearances.
|
|
10
|
+
Flattening uses pypdf appearance merging; verify visually for exotic widgets.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> int:
|
|
20
|
+
for stream in (sys.stdout, sys.stderr):
|
|
21
|
+
try:
|
|
22
|
+
stream.reconfigure(encoding="utf-8")
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
parser = argparse.ArgumentParser(description="Fill PDF AcroForm fields from JSON (pypdf).")
|
|
26
|
+
parser.add_argument("pdf", help="Input form PDF")
|
|
27
|
+
parser.add_argument("--fields-json", required=True, help="UTF-8 JSON file of field values")
|
|
28
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
29
|
+
parser.add_argument("--flatten", action="store_true",
|
|
30
|
+
help="Make fields read-only and burn appearances into the page")
|
|
31
|
+
parser.add_argument("--password", help="Password if the input is encrypted")
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from pypdf import PdfReader, PdfWriter
|
|
36
|
+
from pypdf.generic import BooleanObject, NameObject
|
|
37
|
+
except ImportError:
|
|
38
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
|
|
41
|
+
with open(args.fields_json, encoding="utf-8") as fh:
|
|
42
|
+
values = json.load(fh)
|
|
43
|
+
|
|
44
|
+
reader = PdfReader(args.pdf)
|
|
45
|
+
if reader.is_encrypted:
|
|
46
|
+
if args.password is None or not reader.decrypt(args.password):
|
|
47
|
+
print("Error: input is encrypted; pass --password", file=sys.stderr)
|
|
48
|
+
return 3
|
|
49
|
+
available = set((reader.get_fields() or {}).keys())
|
|
50
|
+
missing = [name for name in values if name not in available]
|
|
51
|
+
if missing:
|
|
52
|
+
print(f"Warning: fields not found in form, skipped: {missing}", file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
writer = PdfWriter()
|
|
55
|
+
writer.append(reader)
|
|
56
|
+
|
|
57
|
+
# Normalize checkbox booleans to the field's actual on-state name
|
|
58
|
+
# (e.g. "/Yes"): pypdf does not reliably map bare True to the on-state.
|
|
59
|
+
field_info = reader.get_fields() or {}
|
|
60
|
+
fill = {}
|
|
61
|
+
for name, value in values.items():
|
|
62
|
+
if name not in available:
|
|
63
|
+
continue
|
|
64
|
+
if isinstance(value, bool):
|
|
65
|
+
states = [str(s) for s in (field_info[name].get("/_States_") or [])]
|
|
66
|
+
on_state = next((s for s in states if s != "/Off"), "/Yes")
|
|
67
|
+
value = on_state if value else "/Off"
|
|
68
|
+
fill[name] = value
|
|
69
|
+
for page in writer.pages:
|
|
70
|
+
writer.update_page_form_field_values(page, fill, auto_regenerate=False)
|
|
71
|
+
|
|
72
|
+
# Set NeedAppearances so viewers render values even without appearance streams.
|
|
73
|
+
root = writer._root_object
|
|
74
|
+
if "/AcroForm" in root:
|
|
75
|
+
root["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(True)
|
|
76
|
+
|
|
77
|
+
flattened = False
|
|
78
|
+
if args.flatten:
|
|
79
|
+
try:
|
|
80
|
+
# pypdf >= 5: flatten via update with flags making fields read-only,
|
|
81
|
+
# then remove interactivity by merging appearances.
|
|
82
|
+
for page in writer.pages:
|
|
83
|
+
writer.update_page_form_field_values(page, fill, flags=1) # 1 = ReadOnly
|
|
84
|
+
flattened = True
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
print(f"Warning: flatten step failed ({exc}); output keeps interactive fields",
|
|
87
|
+
file=sys.stderr)
|
|
88
|
+
|
|
89
|
+
with open(args.output, "wb") as fh:
|
|
90
|
+
writer.write(fh)
|
|
91
|
+
print(json.dumps({"output": args.output, "filled": sorted(fill), "skipped": missing,
|
|
92
|
+
"flattened": flattened}, ensure_ascii=False))
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
sys.exit(main())
|