@cteno/sdk-linux-x64 0.0.0-stage → 2.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE.daemon +31 -0
  2. package/README.md +4 -2
  3. package/agents/flow-analyzer/AGENT.md +71 -0
  4. package/bin/cteno-host-cli +4 -0
  5. package/bin/cteno-wiki +0 -0
  6. package/bin/rg +0 -0
  7. package/bin/secret-exec +0 -0
  8. package/manifest.json +12 -0
  9. package/package.json +33 -4
  10. package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
  11. package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
  12. package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
  13. package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
  14. package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
  15. package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
  16. package/plugins/cteno.inbox/.cteno-plugin/plugin.json +128 -0
  17. package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
  18. package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
  19. package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
  20. package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
  21. package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
  22. package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
  23. package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
  24. package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
  25. package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
  26. package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
  27. package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
  28. package/plugins/cteno.memory/ui/index.html +822 -0
  29. package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
  30. package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
  31. package/plugins/jev/.cteno-plugin/plugin.json +305 -0
  32. package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
  33. package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
  34. package/skills/agent-create/SKILL.md +86 -0
  35. package/skills/cteno-host-cli/SKILL.md +248 -0
  36. package/skills/docx/LICENSE +23 -0
  37. package/skills/docx/NOTICE.md +8 -0
  38. package/skills/docx/SKILL.md +69 -0
  39. package/skills/docx/references/cjk_typography.md +357 -0
  40. package/skills/docx/references/design_principles.md +819 -0
  41. package/skills/docx/references/revisions-and-comments.md +88 -0
  42. package/skills/docx/references/track_changes_guide.md +200 -0
  43. package/skills/docx/references/typography_guide.md +294 -0
  44. package/skills/docx/scripts/cjk_fonts.py +244 -0
  45. package/skills/docx/scripts/docx_comments.py +289 -0
  46. package/skills/docx/scripts/docx_common.py +92 -0
  47. package/skills/docx/scripts/docx_create.py +259 -0
  48. package/skills/docx/scripts/docx_edit.py +250 -0
  49. package/skills/docx/scripts/docx_read.py +149 -0
  50. package/skills/docx/scripts/docx_revisions.py +146 -0
  51. package/skills/docx/scripts/docx_template.py +70 -0
  52. package/skills/docx/scripts/docx_validate.py +156 -0
  53. package/skills/docx/scripts/render.py +250 -0
  54. package/skills/docx/tests/test_docx_skill.py +525 -0
  55. package/skills/home-devices/SKILL.md +121 -0
  56. package/skills/mcp/SKILL.md +72 -0
  57. package/skills/mcp/scripts/mcp_setup.py +439 -0
  58. package/skills/pdf/LICENSE +23 -0
  59. package/skills/pdf/NOTICE.md +8 -0
  60. package/skills/pdf/SKILL.md +63 -0
  61. package/skills/pdf/references/design-tokens.md +381 -0
  62. package/skills/pdf/references/forms.md +99 -0
  63. package/skills/pdf/references/nano-pdf-editing.md +40 -0
  64. package/skills/pdf/references/ocr-extraction.md +165 -0
  65. package/skills/pdf/scripts/_raster.py +76 -0
  66. package/skills/pdf/scripts/extract_marker.py +88 -0
  67. package/skills/pdf/scripts/extract_pymupdf.py +101 -0
  68. package/skills/pdf/scripts/pdf_create.py +191 -0
  69. package/skills/pdf/scripts/pdf_fill_form.py +97 -0
  70. package/skills/pdf/scripts/pdf_form_layout.py +168 -0
  71. package/skills/pdf/scripts/pdf_make_form.py +145 -0
  72. package/skills/pdf/scripts/pdf_merge.py +50 -0
  73. package/skills/pdf/scripts/pdf_meta.py +115 -0
  74. package/skills/pdf/scripts/pdf_page_image.py +99 -0
  75. package/skills/pdf/scripts/pdf_read.py +153 -0
  76. package/skills/pdf/scripts/pdf_secure.py +71 -0
  77. package/skills/pdf/scripts/pdf_split.py +84 -0
  78. package/skills/pdf/scripts/pdf_stamp.py +143 -0
  79. package/skills/pdf/scripts/pdf_watermark.py +51 -0
  80. package/skills/pdf/scripts/render.py +250 -0
  81. package/skills/pdf/tests/test_pdf_skill.py +415 -0
  82. package/skills/pptx/LICENSE +23 -0
  83. package/skills/pptx/NOTICE.md +8 -0
  84. package/skills/pptx/SKILL.md +62 -0
  85. package/skills/pptx/references/design-system.md +392 -0
  86. package/skills/pptx/references/pptxgenjs.md +420 -0
  87. package/skills/pptx/references/slide-types.md +413 -0
  88. package/skills/pptx/scripts/cjk_fonts.py +244 -0
  89. package/skills/pptx/scripts/pptx_create.py +240 -0
  90. package/skills/pptx/scripts/pptx_edit.py +436 -0
  91. package/skills/pptx/scripts/pptx_from_template.py +88 -0
  92. package/skills/pptx/scripts/pptx_read.py +131 -0
  93. package/skills/pptx/scripts/pptx_render.py +96 -0
  94. package/skills/pptx/scripts/render.py +250 -0
  95. package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
  96. package/skills/skill-create/SKILL.md +114 -0
  97. package/skills/video-generation/SKILL.md +27 -0
  98. package/skills/xlsx/LICENSE +23 -0
  99. package/skills/xlsx/NOTICE.md +8 -0
  100. package/skills/xlsx/SKILL.md +63 -0
  101. package/skills/xlsx/references/format.md +71 -0
  102. package/skills/xlsx/references/restructuring.md +71 -0
  103. package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
  104. package/skills/xlsx/scripts/formula_check.py +428 -0
  105. package/skills/xlsx/scripts/render.py +250 -0
  106. package/skills/xlsx/scripts/xlsx_create.py +265 -0
  107. package/skills/xlsx/scripts/xlsx_edit.py +269 -0
  108. package/skills/xlsx/scripts/xlsx_read.py +166 -0
  109. package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
  110. package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
  111. package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
  112. package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
  113. package/tools/create_goal/TOOL.md +36 -0
  114. package/tools/image_generation/TOOL.md +81 -0
  115. package/tools/skill/TOOL.md +117 -0
  116. 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())