crewly 1.20.131 → 1.20.133
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/config/constants.ts +49 -0
- package/config/index.ts +2 -0
- package/config/skills/agent/core/find-skill/SKILL.md +58 -0
- package/config/skills/agent/core/find-skill/execute.sh +67 -0
- package/config/skills/agent/core/find-skill/execute.test.sh +69 -0
- package/config/skills/agent/core/generate-pdf/SKILL.md +24 -29
- package/config/skills/agent/core/generate-pdf/execute.sh +22 -78
- package/config/skills/agent/core/generate-pdf/execute.test.sh +45 -0
- package/config/skills/agent/core/install-skill/SKILL.md +67 -0
- package/config/skills/agent/core/install-skill/execute.sh +80 -0
- package/config/skills/agent/core/install-skill/execute.test.sh +74 -0
- package/config/skills/agent/pdf-tools/SKILL.md +103 -0
- package/config/skills/agent/pdf-tools/execute.sh +301 -0
- package/config/skills/agent/pdf-tools/execute.test.sh +218 -0
- package/config/skills/agent/pdf-tools/instructions.md +14 -0
- package/config/skills/agent/pdf-tools/pdf_tools.py +268 -0
- package/config/skills/agent/pdf-tools/skill.json +103 -0
- package/config/skills/agent/pdf-tools/style.css +143 -0
- package/config/skills/agent/transcribe-audio/SKILL.md +42 -5
- package/config/skills/agent/transcribe-audio/execute.sh +44 -11
- package/config/skills/agent/transcribe-audio/execute.test.sh +90 -0
- package/config/skills/agent/transcribe-audio/install-whisper-cpp.sh +126 -0
- package/config/skills/agent/transcribe-audio/install-whisper-cpp.test.sh +69 -0
- package/config/skills/agent/transcribe-audio/skill.json +67 -6
- package/config/skills/marketplace-registry.ts +8 -1
- package/config/skills/registry.json +337 -3
- package/dist/backend/backend/src/constants.d.ts +71 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +96 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts +56 -0
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js +114 -0
- package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +25 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +73 -55
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts +3 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +9 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts +49 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js +185 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts +21 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js +29 -0
- package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js.map +1 -0
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +5 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts +115 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js +160 -0
- package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +8 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/marketplace/marketplace.service.js +11 -3
- package/dist/backend/backend/src/services/marketplace/marketplace.service.js.map +1 -1
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
- package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts +243 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js +439 -0
- package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
- package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/types/marketplace.types.d.ts +16 -1
- package/dist/backend/backend/src/types/marketplace.types.d.ts.map +1 -1
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.js +78 -0
- package/dist/backend/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
- package/dist/backend/build-info.json +2 -2
- package/dist/backend/config/constants.d.ts +48 -0
- package/dist/backend/config/constants.d.ts.map +1 -1
- package/dist/backend/config/constants.js +48 -0
- package/dist/backend/config/constants.js.map +1 -1
- package/dist/backend/config/index.d.ts +2 -2
- package/dist/backend/config/index.d.ts.map +1 -1
- package/dist/backend/config/index.js +2 -2
- package/dist/backend/config/index.js.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +71 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +96 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
- package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts +101 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.js +392 -0
- package/dist/cli/backend/src/services/marketplace/marketplace.service.js.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
- package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
- package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/cli/backend/src/types/marketplace.types.d.ts +219 -0
- package/dist/cli/backend/src/types/marketplace.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/marketplace.types.js +6 -0
- package/dist/cli/backend/src/types/marketplace.types.js.map +1 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.js +78 -0
- package/dist/cli/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
- package/dist/cli/backend/src/utils/package-root.d.ts +15 -0
- package/dist/cli/backend/src/utils/package-root.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/package-root.js +38 -0
- package/dist/cli/backend/src/utils/package-root.js.map +1 -0
- package/dist/cli/cli/src/commands/cloud.d.ts +45 -25
- package/dist/cli/cli/src/commands/cloud.d.ts.map +1 -1
- package/dist/cli/cli/src/commands/cloud.js +153 -47
- package/dist/cli/cli/src/commands/cloud.js.map +1 -1
- package/dist/cli/cli/src/commands/install.d.ts +13 -1
- package/dist/cli/cli/src/commands/install.d.ts.map +1 -1
- package/dist/cli/cli/src/commands/install.js +40 -3
- package/dist/cli/cli/src/commands/install.js.map +1 -1
- package/dist/cli/cli/src/commands/onboard-checklist.js +3 -3
- package/dist/cli/cli/src/commands/onboard-checklist.js.map +1 -1
- package/dist/cli/cli/src/commands/skills.d.ts +58 -0
- package/dist/cli/cli/src/commands/skills.d.ts.map +1 -0
- package/dist/cli/cli/src/commands/skills.js +132 -0
- package/dist/cli/cli/src/commands/skills.js.map +1 -0
- package/dist/cli/cli/src/index.js +18 -7
- package/dist/cli/cli/src/index.js.map +1 -1
- package/dist/cli/cli/src/utils/archive-creator.d.ts +14 -0
- package/dist/cli/cli/src/utils/archive-creator.d.ts.map +1 -1
- package/dist/cli/cli/src/utils/archive-creator.js +24 -3
- package/dist/cli/cli/src/utils/archive-creator.js.map +1 -1
- package/dist/cli/cli/src/utils/cloud-submit.d.ts.map +1 -1
- package/dist/cli/cli/src/utils/cloud-submit.js +14 -5
- package/dist/cli/cli/src/utils/cloud-submit.js.map +1 -1
- package/dist/cli/cli/src/utils/package-validator.d.ts +2 -0
- package/dist/cli/cli/src/utils/package-validator.d.ts.map +1 -1
- package/dist/cli/cli/src/utils/package-validator.js +16 -0
- package/dist/cli/cli/src/utils/package-validator.js.map +1 -1
- package/dist/cli/config/constants.d.ts +48 -0
- package/dist/cli/config/constants.d.ts.map +1 -1
- package/dist/cli/config/constants.js +48 -0
- package/dist/cli/config/constants.js.map +1 -1
- package/dist/cli/config/index.d.ts +2 -2
- package/dist/cli/config/index.d.ts.map +1 -1
- package/dist/cli/config/index.js +2 -2
- package/dist/cli/config/index.js.map +1 -1
- package/frontend/dist/assets/{index-5b98bf77.js → index-e6c403b6.js} +265 -265
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for pdf-tools — run with: bash execute.test.sh </dev/null
|
|
3
|
+
# Chrome and WeasyPrint are fakes on an isolated PATH; Markdown uses a stub
|
|
4
|
+
# `markdown` module. Reading uses real pypdf when some Python has it
|
|
5
|
+
# (PDF_TOOLS_TEST_PYTHON, then the pdf-tools venv), else pdftotext, else it
|
|
6
|
+
# checks the needsSetup answer. Nothing is installed. Exit 0 on pass.
|
|
7
|
+
set -u
|
|
8
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
EXEC="$HERE/execute.sh"
|
|
10
|
+
PASS=0; FAIL=0; SKIP=0
|
|
11
|
+
check() {
|
|
12
|
+
local name="$1"; local got="$2"; local want="$3"
|
|
13
|
+
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
|
|
14
|
+
}
|
|
15
|
+
contains() {
|
|
16
|
+
local name="$1"; local hay="$2"; local needle="$3"
|
|
17
|
+
case "$hay" in *"$needle"*) PASS=$((PASS+1)) ;; *) FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " missing: $needle"; echo " in: ${hay:0:400}" ;; esac
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
JQ="$(command -v jq)"; BASH_BIN="${TEST_BASH:-$(command -v bash)}"; SYS_PY="$(command -v python3)"
|
|
21
|
+
[ -n "$JQ" ] && [ -n "$SYS_PY" ] || { echo "jq and python3 are required to run these tests"; exit 1; }
|
|
22
|
+
T="$(mktemp -d)"
|
|
23
|
+
trap 'rm -rf "$T"' EXIT
|
|
24
|
+
mkdir -p "$T/bin" "$T/home" "$T/stub/markdown" "$T/docs/img"
|
|
25
|
+
for tool in bash jq python3 mktemp cat rm tail head dirname mkdir sed tr wc sleep kill id awk grep basename cp uname date; do
|
|
26
|
+
src="$(command -v "$tool" 2>/dev/null)" && ln -sf "$src" "$T/bin/$tool"
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
# A real one-page PDF with the text "Hello Crewly PDF".
|
|
30
|
+
cat > "$T/mkpdf.py" <<'PY'
|
|
31
|
+
import sys
|
|
32
|
+
text = sys.argv[2].encode()
|
|
33
|
+
content = b"BT /F1 18 Tf 20 70 Td (" + text + b") Tj ET"
|
|
34
|
+
objs = [
|
|
35
|
+
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
36
|
+
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
37
|
+
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
|
38
|
+
b"<< /Length %d >>\nstream\n" % len(content) + content + b"\nendstream",
|
|
39
|
+
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
40
|
+
]
|
|
41
|
+
out = b"%PDF-1.4\n"
|
|
42
|
+
offsets = []
|
|
43
|
+
for i, o in enumerate(objs, 1):
|
|
44
|
+
offsets.append(len(out))
|
|
45
|
+
out += b"%d 0 obj\n" % i + o + b"\nendobj\n"
|
|
46
|
+
xref = len(out)
|
|
47
|
+
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
|
|
48
|
+
for off in offsets:
|
|
49
|
+
out += b"%010d 00000 n \n" % off
|
|
50
|
+
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
|
|
51
|
+
open(sys.argv[1], "wb").write(out)
|
|
52
|
+
PY
|
|
53
|
+
"$SYS_PY" "$T/mkpdf.py" "$T/fixture.pdf" "Hello Crewly PDF"
|
|
54
|
+
|
|
55
|
+
# Fake Chrome: records its argv, "prints" the fixture to --print-to-pdf.
|
|
56
|
+
cat > "$T/fake-chrome" <<EOF
|
|
57
|
+
#!/bin/bash
|
|
58
|
+
printf '%s\n' "\$@" > "$T/chrome.args"
|
|
59
|
+
for a in "\$@"; do case "\$a" in --print-to-pdf=*) cp "$T/fixture.pdf" "\${a#--print-to-pdf=}" ;; esac; done
|
|
60
|
+
EOF
|
|
61
|
+
chmod 755 "$T/fake-chrome"
|
|
62
|
+
# Chrome that crashes without output.
|
|
63
|
+
printf '#!/bin/bash\nexit 1\n' > "$T/broken-chrome"; chmod 755 "$T/broken-chrome"
|
|
64
|
+
|
|
65
|
+
# Stub python-markdown: wraps each line in <p>, enough to prove the pipeline.
|
|
66
|
+
cat > "$T/stub/markdown/__init__.py" <<'PY'
|
|
67
|
+
import html
|
|
68
|
+
def markdown(text, **_kw):
|
|
69
|
+
return "\n".join("<p>%s</p>" % html.escape(line) for line in text.splitlines() if line.strip())
|
|
70
|
+
PY
|
|
71
|
+
|
|
72
|
+
run() {
|
|
73
|
+
env -i HOME="$T/home" PATH="$T/bin" CREWLY_HOME="$T/home/.crewly" PYTHONPATH="$T/stub" \
|
|
74
|
+
PDF_TOOLS_CHROME_PATHS="" PDF_TOOLS_CHROME_TIMEOUT_SEC=10 "$@" "$BASH_BIN" "$EXEC" "$JSON" 2>/dev/null </dev/null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
printf '# 季度报告\n\nRevenue grew **12%%**.\n\n\n' > "$T/docs/report.md"
|
|
78
|
+
printf '<html><head><style>h1{color:red}</style></head><body><h1>Brief</h1></body></html>\n' > "$T/docs/brief.html"
|
|
79
|
+
|
|
80
|
+
# 1. Markdown → PDF with (fake) Chrome.
|
|
81
|
+
JSON="{\"action\":\"render\",\"input\":\"$T/docs/report.md\",\"title\":\"Q3 报告\"}"
|
|
82
|
+
OUT=$(run CHROME_BIN="$T/fake-chrome")
|
|
83
|
+
check "md: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
|
|
84
|
+
check "md: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "chrome"
|
|
85
|
+
check "md: default output path" "$(printf '%s' "$OUT" | "$JQ" -r .pdf)" "$T/docs/report.pdf"
|
|
86
|
+
check "md: output is a PDF" "$(head -c 4 "$T/docs/report.pdf")" "%PDF"
|
|
87
|
+
ARGS="$(cat "$T/chrome.args" 2>/dev/null)"
|
|
88
|
+
contains "chrome: headless new" "$ARGS" "--headless=new"
|
|
89
|
+
contains "chrome: no header/footer" "$ARGS" "--no-pdf-header-footer"
|
|
90
|
+
contains "chrome: print target" "$ARGS" "--print-to-pdf=$T/docs/report.pdf"
|
|
91
|
+
case "$ARGS" in *--user-data-dir*) FAIL=$((FAIL+1)); echo "FAIL: chrome: no --user-data-dir (it makes Chrome hang on macOS)";; *) PASS=$((PASS+1));; esac
|
|
92
|
+
HTML_URL="$(tail -n 1 "$T/chrome.args")"
|
|
93
|
+
check "chrome: renders a file:// URL" "${HTML_URL:0:7}" "file://"
|
|
94
|
+
check "md: nothing written next to the markdown" "$(ls -A "$T/docs" | tr '\n' ' ')" "brief.html img report.md report.pdf "
|
|
95
|
+
|
|
96
|
+
# The generated HTML (captured by a Chrome that copies it) carries the stylesheet, title and <base>.
|
|
97
|
+
cat > "$T/capture-chrome" <<EOF
|
|
98
|
+
#!/bin/bash
|
|
99
|
+
for a in "\$@"; do case "\$a" in file://*) f="\${a#file://}"; cp "\$(printf '%b' "\${f//%/\\\\x}")" "$T/captured.html" ;; --print-to-pdf=*) cp "$T/fixture.pdf" "\${a#--print-to-pdf=}" ;; esac; done
|
|
100
|
+
EOF
|
|
101
|
+
chmod 755 "$T/capture-chrome"
|
|
102
|
+
OUT=$(run CHROME_BIN="$T/capture-chrome")
|
|
103
|
+
CAP="$(cat "$T/captured.html" 2>/dev/null)"
|
|
104
|
+
contains "md html: CJK font stack" "$CAP" '"PingFang SC"'
|
|
105
|
+
contains "md html: A4 page rule" "$CAP" "size: A4"
|
|
106
|
+
contains "md html: title" "$CAP" "<title>Q3 报告</title>"
|
|
107
|
+
contains "md html: base href to the markdown dir" "$CAP" "<base href=\"file://$T/docs/\">"
|
|
108
|
+
contains "md html: body converted" "$CAP" "<p># 季度报告</p>"
|
|
109
|
+
|
|
110
|
+
# Chrome that writes the PDF and then hangs (seen with Chrome 153 on macOS) is
|
|
111
|
+
# stopped once the PDF is complete instead of blocking until the timeout.
|
|
112
|
+
cat > "$T/hanging-chrome" <<EOF
|
|
113
|
+
#!/bin/bash
|
|
114
|
+
for a in "\$@"; do case "\$a" in --print-to-pdf=*) cp "$T/fixture.pdf" "\${a#--print-to-pdf=}" ;; esac; done
|
|
115
|
+
exec sleep 60
|
|
116
|
+
EOF
|
|
117
|
+
chmod 755 "$T/hanging-chrome"
|
|
118
|
+
START=$(date +%s)
|
|
119
|
+
JSON="{\"action\":\"render\",\"input\":\"$T/docs/report.md\",\"output\":\"$T/out/hang.pdf\"}"
|
|
120
|
+
OUT=$(run CHROME_BIN="$T/hanging-chrome")
|
|
121
|
+
ELAPSED=$(( $(date +%s) - START ))
|
|
122
|
+
check "hanging chrome: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
|
|
123
|
+
[ "$ELAPSED" -lt 9 ] && OK=yes || OK="no (${ELAPSED}s)"
|
|
124
|
+
check "hanging chrome: stopped after the grace period, not the timeout" "$OK" "yes"
|
|
125
|
+
|
|
126
|
+
# 2. Styled HTML is printed as-is (the original file, not a copy).
|
|
127
|
+
JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"output\":\"$T/out/brief.pdf\"}"
|
|
128
|
+
OUT=$(run CHROME_BIN="$T/fake-chrome")
|
|
129
|
+
check "html: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
|
|
130
|
+
check "html: printed the original file" "$(tail -n 1 "$T/chrome.args")" "file://$T/docs/brief.html"
|
|
131
|
+
check "html: output dir created" "$(head -c 4 "$T/out/brief.pdf")" "%PDF"
|
|
132
|
+
|
|
133
|
+
# 3. addDefaultStyle prepends the stylesheet to a copy.
|
|
134
|
+
JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"output\":\"$T/out/b2.pdf\",\"addDefaultStyle\":true}"
|
|
135
|
+
OUT=$(run CHROME_BIN="$T/capture-chrome")
|
|
136
|
+
CAP="$(cat "$T/captured.html")"
|
|
137
|
+
contains "addDefaultStyle: stylesheet added" "$CAP" '"PingFang SC"'
|
|
138
|
+
contains "addDefaultStyle: document kept" "$CAP" "h1{color:red}"
|
|
139
|
+
|
|
140
|
+
# 4. engine=chrome with no Chrome → needsSetup.
|
|
141
|
+
JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"engine\":\"chrome\"}"
|
|
142
|
+
OUT=$(run)
|
|
143
|
+
check "no chrome: needsSetup" "$(printf '%s' "$OUT" | "$JQ" -r .needsSetup)" "true"
|
|
144
|
+
check "no chrome: skill" "$(printf '%s' "$OUT" | "$JQ" -r .skill)" "pdf-tools"
|
|
145
|
+
check "no chrome: missing" "$(printf '%s' "$OUT" | "$JQ" -c .missing)" '["chrome"]'
|
|
146
|
+
|
|
147
|
+
# 5. No engine at all → needsSetup naming both engines.
|
|
148
|
+
JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\"}"
|
|
149
|
+
OUT=$(run)
|
|
150
|
+
check "no engine: missing" "$(printf '%s' "$OUT" | "$JQ" -c .missing)" '["chrome","weasyprint-libs"]'
|
|
151
|
+
|
|
152
|
+
# 6. Chrome crashes → WeasyPrint fallback (fake weasyprint CLI).
|
|
153
|
+
printf '#!/bin/bash\ncp "%s" "$2"\n' "$T/fixture.pdf" > "$T/bin/weasyprint"; chmod 755 "$T/bin/weasyprint"
|
|
154
|
+
JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"output\":\"$T/out/w.pdf\"}"
|
|
155
|
+
OUT=$(run CHROME_BIN="$T/broken-chrome")
|
|
156
|
+
check "fallback: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "weasyprint"
|
|
157
|
+
check "fallback: pdf" "$(head -c 4 "$T/out/w.pdf")" "%PDF"
|
|
158
|
+
rm -f "$T/bin/weasyprint"
|
|
159
|
+
|
|
160
|
+
# 7. Bad input.
|
|
161
|
+
JSON='{"action":"render"}'
|
|
162
|
+
check "no input: error" "$(run | "$JQ" -r .error)" "input (a .md/.html file) or inputs (a list of .md/.html/.pdf parts) is required"
|
|
163
|
+
JSON='{"action":"explode"}'
|
|
164
|
+
check "unknown action" "$(run | "$JQ" -r .error)" "unknown action: explode (use render, merge, read, info or check)"
|
|
165
|
+
JSON="{\"action\":\"read\",\"input\":\"$T/docs/report.md\"}"
|
|
166
|
+
check "read non-pdf" "$(run | "$JQ" -r .error)" "not a PDF (or empty): $T/docs/report.md"
|
|
167
|
+
|
|
168
|
+
# 8. Reading + merging with real pypdf when available.
|
|
169
|
+
PYPDF_PY=""
|
|
170
|
+
for cand in "${PDF_TOOLS_TEST_PYTHON:-}" "${CREWLY_HOME:-$HOME/.crewly}/venv/pdf-tools/bin/python3" "$SYS_PY"; do
|
|
171
|
+
[ -n "$cand" ] && [ -x "$cand" ] && "$cand" -c "import pypdf" >/dev/null 2>&1 && { PYPDF_PY="$cand"; break; }
|
|
172
|
+
done
|
|
173
|
+
if [ -n "$PYPDF_PY" ]; then
|
|
174
|
+
JSON="{\"action\":\"read\",\"input\":\"$T/fixture.pdf\"}"
|
|
175
|
+
OUT=$(run PDF_TOOLS_PYTHON="$PYPDF_PY")
|
|
176
|
+
check "read: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "pypdf"
|
|
177
|
+
check "read: pages" "$(printf '%s' "$OUT" | "$JQ" -r .pages)" "1"
|
|
178
|
+
contains "read: text" "$(printf '%s' "$OUT" | "$JQ" -r .text)" "Hello Crewly PDF"
|
|
179
|
+
JSON="{\"action\":\"read\",\"input\":\"$T/fixture.pdf\",\"maxChars\":5,\"textFile\":\"$T/all.txt\"}"
|
|
180
|
+
OUT=$(run PDF_TOOLS_PYTHON="$PYPDF_PY")
|
|
181
|
+
check "read: truncated" "$(printf '%s' "$OUT" | "$JQ" -r .truncated)" "true"
|
|
182
|
+
contains "read: full text on disk" "$(cat "$T/all.txt")" "Hello Crewly PDF"
|
|
183
|
+
|
|
184
|
+
"$SYS_PY" "$T/mkpdf.py" "$T/second.pdf" "Second part"
|
|
185
|
+
JSON="{\"action\":\"render\",\"inputs\":[\"$T/docs/brief.html\",\"$T/second.pdf\"],\"output\":\"$T/out/merged.pdf\"}"
|
|
186
|
+
OUT=$(run PDF_TOOLS_PYTHON="$PYPDF_PY" CHROME_BIN="$T/fake-chrome")
|
|
187
|
+
check "multi-part: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
|
|
188
|
+
check "multi-part: parts" "$(printf '%s' "$OUT" | "$JQ" -r .parts)" "2"
|
|
189
|
+
JSON="{\"action\":\"info\",\"input\":\"$T/out/merged.pdf\"}"
|
|
190
|
+
check "multi-part: 2 pages" "$(run PDF_TOOLS_PYTHON="$PYPDF_PY" | "$JQ" -r .pages)" "2"
|
|
191
|
+
JSON="{\"action\":\"read\",\"input\":\"$T/out/merged.pdf\",\"pages\":\"2\"}"
|
|
192
|
+
contains "multi-part: order kept" "$(run PDF_TOOLS_PYTHON="$PYPDF_PY" | "$JQ" -r .text)" "Second part"
|
|
193
|
+
else
|
|
194
|
+
SKIP=$((SKIP+1)); echo "SKIP: pypdf not available (set PDF_TOOLS_TEST_PYTHON) — checking the needsSetup answer instead"
|
|
195
|
+
JSON="{\"action\":\"merge\",\"inputs\":[\"$T/fixture.pdf\",\"$T/fixture.pdf\"],\"output\":\"$T/out/m.pdf\"}"
|
|
196
|
+
OUT=$(run)
|
|
197
|
+
check "merge without pypdf: needsSetup" "$(printf '%s' "$OUT" | "$JQ" -r .needsSetup)" "true"
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
# 9. Reading without pypdf falls back to pdftotext when the machine has it.
|
|
201
|
+
if command -v pdftotext >/dev/null 2>&1; then
|
|
202
|
+
ln -sf "$(command -v pdftotext)" "$T/bin/pdftotext"
|
|
203
|
+
JSON="{\"action\":\"read\",\"input\":\"$T/fixture.pdf\"}"
|
|
204
|
+
OUT=$(run PDF_TOOLS_PYTHON="$SYS_PY")
|
|
205
|
+
if "$SYS_PY" -c "import pypdf" >/dev/null 2>&1; then EXPECT=pypdf; else EXPECT=pdftotext; fi
|
|
206
|
+
check "read fallback: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "$EXPECT"
|
|
207
|
+
contains "read fallback: text" "$(printf '%s' "$OUT" | "$JQ" -r .text)" "Hello Crewly PDF"
|
|
208
|
+
rm -f "$T/bin/pdftotext"
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
# 10. check action + the shipped setup block.
|
|
212
|
+
JSON='{"action":"check"}'
|
|
213
|
+
OUT=$(run CHROME_BIN="$T/fake-chrome")
|
|
214
|
+
check "check: canRender" "$(printf '%s' "$OUT" | "$JQ" -r .canRender)" "true"
|
|
215
|
+
check "setup: venv name reuses pdf-tools" "$("$JQ" -r '.setup.steps[] | select(.type=="python") | .venv' "$HERE/skill.json")" "pdf-tools"
|
|
216
|
+
|
|
217
|
+
echo "=== Results: $PASS passed, $FAIL failed, $SKIP skipped ==="
|
|
218
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# pdf-tools — agent instructions
|
|
2
|
+
|
|
3
|
+
- **Making a document someone will read:** write Markdown (or styled HTML when
|
|
4
|
+
layout matters), then `render`. Check the result: `info` for the page count,
|
|
5
|
+
and open or `read` it before sending. Send the file itself (`core/attach-file`),
|
|
6
|
+
not a path.
|
|
7
|
+
- **Someone sent a PDF:** `read` it (with `pages` for long files, `textFile` to
|
|
8
|
+
keep all of it) before answering. If the result has a `note` about no text
|
|
9
|
+
layer, say the PDF is scanned instead of guessing its content.
|
|
10
|
+
- **Several parts** (cover + body + appendix): pass them as `inputs` in order;
|
|
11
|
+
existing PDFs can be parts too.
|
|
12
|
+
- **`needsSetup: true`** in any result: tell the user in one line that you are
|
|
13
|
+
installing the PDF tools (about 3 minutes), run
|
|
14
|
+
`install-skill --id pdf-tools`, and continue when `[SKILL INSTALLED]` arrives.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pdf-tools helper — the Python half of the Crewly `pdf-tools` skill.
|
|
3
|
+
|
|
4
|
+
Runs inside the skill's venv ($CREWLY_HOME/venv/pdf-tools). Every command
|
|
5
|
+
prints one JSON object on stdout and exits non-zero on failure.
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
md2html <in.md> <out.html> [--title T] [--css FILE ...] [--lang zh] [--base URL]
|
|
9
|
+
weasyprint <in.html> <out.pdf>
|
|
10
|
+
merge <out.pdf> <in.pdf> [<in.pdf> ...]
|
|
11
|
+
read <in.pdf> [--pages 1-3,5] [--max-chars N] [--text-file OUT.txt]
|
|
12
|
+
info <in.pdf>
|
|
13
|
+
|
|
14
|
+
Written for Crewly (MIT). Uses pypdf, python-markdown and weasyprint.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import html
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
# Characters of extracted text returned inline before truncating (the rest
|
|
26
|
+
# goes to --text-file when given).
|
|
27
|
+
DEFAULT_MAX_CHARS = 20000
|
|
28
|
+
# Markdown extensions: tables, fenced code, heading ids, sane lists, footnotes.
|
|
29
|
+
MARKDOWN_EXTENSIONS = ["extra", "toc", "sane_lists", "smarty"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(obj, code=0):
|
|
33
|
+
"""Print one JSON object and exit."""
|
|
34
|
+
print(json.dumps(obj, ensure_ascii=False))
|
|
35
|
+
sys.exit(code)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fail(message, **extra):
|
|
39
|
+
"""Print a failure object and exit 1."""
|
|
40
|
+
emit(dict(success=False, error=message, **extra), 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_md2html(args):
|
|
44
|
+
"""Convert Markdown to a standalone HTML document with the given stylesheets."""
|
|
45
|
+
try:
|
|
46
|
+
import markdown # noqa: WPS433 — optional dependency, imported on use
|
|
47
|
+
except ImportError:
|
|
48
|
+
fail("python-markdown is not installed in the pdf-tools venv", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
|
|
49
|
+
with open(args.input, encoding="utf-8") as fh:
|
|
50
|
+
body = markdown.markdown(fh.read(), extensions=MARKDOWN_EXTENSIONS, output_format="html5")
|
|
51
|
+
styles = []
|
|
52
|
+
for css in args.css or []:
|
|
53
|
+
with open(css, encoding="utf-8") as fh:
|
|
54
|
+
styles.append(fh.read())
|
|
55
|
+
title = html.escape(args.title or os.path.splitext(os.path.basename(args.input))[0])
|
|
56
|
+
base = f'<base href="{html.escape(args.base)}">' if args.base else ""
|
|
57
|
+
doc = (
|
|
58
|
+
f'<!DOCTYPE html>\n<html lang="{html.escape(args.lang)}"><head><meta charset="utf-8">{base}'
|
|
59
|
+
f"<title>{title}</title><style>\n" + "\n".join(styles) + "\n</style></head>\n"
|
|
60
|
+
f'<body><main class="doc">\n{body}\n</main></body></html>\n'
|
|
61
|
+
)
|
|
62
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
63
|
+
fh.write(doc)
|
|
64
|
+
emit({"success": True, "html": args.output})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_weasyprint(args):
|
|
68
|
+
"""Render HTML to PDF with WeasyPrint (the fallback engine)."""
|
|
69
|
+
try:
|
|
70
|
+
import weasyprint # noqa: WPS433
|
|
71
|
+
except Exception as exc: # ImportError, or OSError when pango is missing
|
|
72
|
+
fail(f"weasyprint is not usable: {exc}", needsSetup=True, skill="pdf-tools", missing=["weasyprint-libs"])
|
|
73
|
+
base = os.path.dirname(os.path.abspath(args.input))
|
|
74
|
+
weasyprint.HTML(filename=args.input, base_url=base).write_pdf(args.output)
|
|
75
|
+
emit({"success": True, "pdf": args.output})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_pypdf():
|
|
79
|
+
"""Import pypdf or fail with a needsSetup object."""
|
|
80
|
+
try:
|
|
81
|
+
import pypdf # noqa: WPS433
|
|
82
|
+
return pypdf
|
|
83
|
+
except ImportError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_merge(args):
|
|
88
|
+
"""Concatenate PDFs in order."""
|
|
89
|
+
pypdf = load_pypdf()
|
|
90
|
+
if pypdf is None:
|
|
91
|
+
fail("pypdf is not installed in the pdf-tools venv", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
|
|
92
|
+
writer = pypdf.PdfWriter()
|
|
93
|
+
pages = 0
|
|
94
|
+
for part in args.inputs:
|
|
95
|
+
reader = pypdf.PdfReader(part)
|
|
96
|
+
for page in reader.pages:
|
|
97
|
+
writer.add_page(page)
|
|
98
|
+
pages += 1
|
|
99
|
+
with open(args.output, "wb") as fh:
|
|
100
|
+
writer.write(fh)
|
|
101
|
+
emit({"success": True, "pdf": args.output, "pages": pages, "parts": len(args.inputs)})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_pages(spec, total):
|
|
105
|
+
"""Turn '1-3,5' into zero-based page indexes within range."""
|
|
106
|
+
if not spec:
|
|
107
|
+
return list(range(total))
|
|
108
|
+
wanted = []
|
|
109
|
+
for chunk in spec.split(","):
|
|
110
|
+
chunk = chunk.strip()
|
|
111
|
+
if not chunk:
|
|
112
|
+
continue
|
|
113
|
+
if "-" in chunk:
|
|
114
|
+
lo, hi = chunk.split("-", 1)
|
|
115
|
+
start = int(lo) if lo else 1
|
|
116
|
+
end = int(hi) if hi else total
|
|
117
|
+
wanted.extend(range(start, end + 1))
|
|
118
|
+
else:
|
|
119
|
+
wanted.append(int(chunk))
|
|
120
|
+
return [p - 1 for p in wanted if 1 <= p <= total]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def pdftotext_all(path):
|
|
124
|
+
"""Extract text per page with poppler's pdftotext (fallback).
|
|
125
|
+
|
|
126
|
+
pdftotext ends every page with a form feed, so splitting on it gives one
|
|
127
|
+
entry per page. Returns None when pdftotext is not installed or fails.
|
|
128
|
+
"""
|
|
129
|
+
exe = shutil.which("pdftotext")
|
|
130
|
+
if not exe:
|
|
131
|
+
return None
|
|
132
|
+
out = subprocess.run(
|
|
133
|
+
[exe, "-layout", "-enc", "UTF-8", path, "-"],
|
|
134
|
+
capture_output=True, text=True, check=False, stdin=subprocess.DEVNULL,
|
|
135
|
+
)
|
|
136
|
+
if out.returncode != 0:
|
|
137
|
+
return None
|
|
138
|
+
pages = out.stdout.split("\f")
|
|
139
|
+
if pages and not pages[-1].strip():
|
|
140
|
+
pages = pages[:-1]
|
|
141
|
+
return pages
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_read(args):
|
|
145
|
+
"""Extract text from a PDF (pypdf; pdftotext when pypdf finds none)."""
|
|
146
|
+
pypdf = load_pypdf()
|
|
147
|
+
engine = None
|
|
148
|
+
texts = None
|
|
149
|
+
total = None
|
|
150
|
+
indexes = []
|
|
151
|
+
if pypdf is not None:
|
|
152
|
+
reader = pypdf.PdfReader(args.input)
|
|
153
|
+
if reader.is_encrypted:
|
|
154
|
+
try:
|
|
155
|
+
reader.decrypt("")
|
|
156
|
+
except Exception: # noqa: BLE001 — any failure means we cannot read it
|
|
157
|
+
fail("the PDF is encrypted and needs a password")
|
|
158
|
+
total = len(reader.pages)
|
|
159
|
+
indexes = parse_pages(args.pages, total)
|
|
160
|
+
texts = [(reader.pages[i].extract_text() or "") for i in indexes]
|
|
161
|
+
engine = "pypdf"
|
|
162
|
+
if texts is None or not "".join(texts).strip():
|
|
163
|
+
whole = pdftotext_all(args.input)
|
|
164
|
+
if whole is not None:
|
|
165
|
+
total = len(whole)
|
|
166
|
+
fb_indexes = parse_pages(args.pages, total)
|
|
167
|
+
fallback = [whole[i] for i in fb_indexes]
|
|
168
|
+
if texts is None or "".join(fallback).strip():
|
|
169
|
+
texts, engine, indexes = fallback, "pdftotext", fb_indexes
|
|
170
|
+
if texts is None:
|
|
171
|
+
fail("cannot read PDFs yet: neither pypdf nor pdftotext is installed", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
|
|
172
|
+
full = "\n\n".join(f"--- page {i + 1} ---\n{t.strip()}" for i, t in zip(indexes, texts))
|
|
173
|
+
result = {
|
|
174
|
+
"success": True,
|
|
175
|
+
"engine": engine,
|
|
176
|
+
"pages": total,
|
|
177
|
+
"pagesRead": [i + 1 for i in indexes],
|
|
178
|
+
"chars": len(full),
|
|
179
|
+
}
|
|
180
|
+
if not "".join(texts).strip():
|
|
181
|
+
result["note"] = "No extractable text: the PDF is probably scanned images. Use OCR or read the page images."
|
|
182
|
+
if args.text_file:
|
|
183
|
+
with open(args.text_file, "w", encoding="utf-8") as fh:
|
|
184
|
+
fh.write(full)
|
|
185
|
+
result["textFile"] = args.text_file
|
|
186
|
+
if len(full) > args.max_chars:
|
|
187
|
+
result["text"] = full[: args.max_chars]
|
|
188
|
+
result["truncated"] = True
|
|
189
|
+
else:
|
|
190
|
+
result["text"] = full
|
|
191
|
+
result["truncated"] = False
|
|
192
|
+
emit(result)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def pdf_page_count(path):
|
|
196
|
+
"""Page count via pdfinfo (poppler) when pypdf is unavailable."""
|
|
197
|
+
exe = shutil.which("pdfinfo")
|
|
198
|
+
if not exe:
|
|
199
|
+
return None
|
|
200
|
+
out = subprocess.run([exe, path], capture_output=True, text=True, check=False, stdin=subprocess.DEVNULL)
|
|
201
|
+
for line in out.stdout.splitlines():
|
|
202
|
+
if line.startswith("Pages:"):
|
|
203
|
+
return int(line.split(":", 1)[1].strip())
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_info(args):
|
|
208
|
+
"""Page count, encryption and document metadata."""
|
|
209
|
+
pypdf = load_pypdf()
|
|
210
|
+
if pypdf is None:
|
|
211
|
+
pages = pdf_page_count(args.input)
|
|
212
|
+
if pages is None:
|
|
213
|
+
fail("pypdf is not installed in the pdf-tools venv", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
|
|
214
|
+
emit({"success": True, "pages": pages, "engine": "pdfinfo"})
|
|
215
|
+
reader = pypdf.PdfReader(args.input)
|
|
216
|
+
meta = {}
|
|
217
|
+
if reader.metadata:
|
|
218
|
+
for key, value in reader.metadata.items():
|
|
219
|
+
meta[str(key).lstrip("/")] = str(value)
|
|
220
|
+
emit({"success": True, "pages": len(reader.pages), "encrypted": reader.is_encrypted, "metadata": meta, "engine": "pypdf"})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main():
|
|
224
|
+
"""Parse arguments and dispatch."""
|
|
225
|
+
parser = argparse.ArgumentParser(prog="pdf_tools.py")
|
|
226
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
227
|
+
|
|
228
|
+
p = sub.add_parser("md2html")
|
|
229
|
+
p.add_argument("input")
|
|
230
|
+
p.add_argument("output")
|
|
231
|
+
p.add_argument("--title")
|
|
232
|
+
p.add_argument("--css", action="append")
|
|
233
|
+
p.add_argument("--lang", default="zh")
|
|
234
|
+
p.add_argument("--base", help="<base href> for relative links (file:// URL of the source directory)")
|
|
235
|
+
p.set_defaults(func=cmd_md2html)
|
|
236
|
+
|
|
237
|
+
p = sub.add_parser("weasyprint")
|
|
238
|
+
p.add_argument("input")
|
|
239
|
+
p.add_argument("output")
|
|
240
|
+
p.set_defaults(func=cmd_weasyprint)
|
|
241
|
+
|
|
242
|
+
p = sub.add_parser("merge")
|
|
243
|
+
p.add_argument("output")
|
|
244
|
+
p.add_argument("inputs", nargs="+")
|
|
245
|
+
p.set_defaults(func=cmd_merge)
|
|
246
|
+
|
|
247
|
+
p = sub.add_parser("read")
|
|
248
|
+
p.add_argument("input")
|
|
249
|
+
p.add_argument("--pages")
|
|
250
|
+
p.add_argument("--max-chars", type=int, default=DEFAULT_MAX_CHARS)
|
|
251
|
+
p.add_argument("--text-file")
|
|
252
|
+
p.set_defaults(func=cmd_read)
|
|
253
|
+
|
|
254
|
+
p = sub.add_parser("info")
|
|
255
|
+
p.add_argument("input")
|
|
256
|
+
p.set_defaults(func=cmd_info)
|
|
257
|
+
|
|
258
|
+
args = parser.parse_args()
|
|
259
|
+
try:
|
|
260
|
+
args.func(args)
|
|
261
|
+
except SystemExit:
|
|
262
|
+
raise
|
|
263
|
+
except Exception as exc: # noqa: BLE001 — report every failure as JSON
|
|
264
|
+
fail(f"{type(exc).__name__}: {exc}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "pdf-tools",
|
|
3
|
+
"name": "pdf-tools",
|
|
4
|
+
"displayName": "PDF Tools (make + read PDFs)",
|
|
5
|
+
"description": "Make polished PDFs from Markdown or styled HTML (headless Chrome preferred, WeasyPrint fallback, clean CJK-ready default stylesheet), merge several parts into one PDF, and read PDFs a user sends (text extraction with pypdf / pdftotext). Declares its own setup: Python venv with pypdf, markdown and weasyprint, plus optional Chrome and CJK fonts.",
|
|
6
|
+
"category": "content-creation",
|
|
7
|
+
"assignableRoles": ["*"],
|
|
8
|
+
"version": "1.0.0",
|
|
9
|
+
"author": "Crewly Team",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"tags": [
|
|
12
|
+
"pdf",
|
|
13
|
+
"document",
|
|
14
|
+
"report",
|
|
15
|
+
"markdown",
|
|
16
|
+
"html",
|
|
17
|
+
"chrome",
|
|
18
|
+
"weasyprint",
|
|
19
|
+
"merge",
|
|
20
|
+
"read-pdf",
|
|
21
|
+
"extract-text",
|
|
22
|
+
"pypdf",
|
|
23
|
+
"cjk",
|
|
24
|
+
"中文",
|
|
25
|
+
"文档"
|
|
26
|
+
],
|
|
27
|
+
"triggers": [
|
|
28
|
+
"make a pdf",
|
|
29
|
+
"generate pdf",
|
|
30
|
+
"markdown to pdf",
|
|
31
|
+
"html to pdf",
|
|
32
|
+
"merge pdfs",
|
|
33
|
+
"read this pdf",
|
|
34
|
+
"extract text from pdf"
|
|
35
|
+
],
|
|
36
|
+
"execution": {
|
|
37
|
+
"type": "script",
|
|
38
|
+
"script": { "file": "execute.sh", "interpreter": "bash", "timeoutMs": 300000 }
|
|
39
|
+
},
|
|
40
|
+
"setup": {
|
|
41
|
+
"estimatedMinutes": 3,
|
|
42
|
+
"steps": [
|
|
43
|
+
{
|
|
44
|
+
"id": "jq",
|
|
45
|
+
"type": "command",
|
|
46
|
+
"description": "jq — parses the skill's JSON input and output",
|
|
47
|
+
"check": { "commands": ["jq"] },
|
|
48
|
+
"install": { "darwin": { "brew": ["jq"] }, "debian": { "apt": ["jq"] } }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "python3",
|
|
52
|
+
"type": "command",
|
|
53
|
+
"description": "Python 3 with venv support",
|
|
54
|
+
"check": { "shell": "python3 -c 'import venv, ensurepip' >/dev/null 2>&1" },
|
|
55
|
+
"install": { "darwin": { "brew": ["python"] }, "debian": { "apt": ["python3", "python3-venv"] } },
|
|
56
|
+
"manualHint": "Install Python 3 (with the venv module) and put python3 on PATH."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "python-packages",
|
|
60
|
+
"type": "python",
|
|
61
|
+
"description": "pypdf (merge/read), markdown and weasyprint in ~/.crewly/venv/pdf-tools",
|
|
62
|
+
"venv": "pdf-tools",
|
|
63
|
+
"packages": ["pypdf>=4", "markdown>=3.5", "weasyprint>=60"],
|
|
64
|
+
"imports": ["pypdf", "markdown"]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "weasyprint-libs",
|
|
68
|
+
"type": "command",
|
|
69
|
+
"optional": true,
|
|
70
|
+
"description": "Pango for WeasyPrint (the fallback engine when there is no Chrome)",
|
|
71
|
+
"check": { "shell": "\"$CREWLY_HOME/venv/pdf-tools/bin/python3\" -c 'import weasyprint' >/dev/null 2>&1" },
|
|
72
|
+
"install": {
|
|
73
|
+
"darwin": { "brew": ["pango"] },
|
|
74
|
+
"debian": { "apt": ["libpango-1.0-0", "libpangoft2-1.0-0", "libharfbuzz-subset0"] }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "chrome",
|
|
79
|
+
"type": "command",
|
|
80
|
+
"optional": true,
|
|
81
|
+
"description": "Chrome/Chromium — the preferred engine (headless print-to-pdf)",
|
|
82
|
+
"check": {
|
|
83
|
+
"commands": ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"],
|
|
84
|
+
"paths": [
|
|
85
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
86
|
+
"~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
87
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
"install": { "debian": { "apt": ["chromium"] } },
|
|
91
|
+
"manualHint": "Install Google Chrome; until then PDFs are rendered with WeasyPrint."
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "cjk-fonts",
|
|
95
|
+
"type": "command",
|
|
96
|
+
"optional": true,
|
|
97
|
+
"description": "Chinese/Japanese/Korean fonts (Noto CJK) so CJK text is not blank boxes",
|
|
98
|
+
"check": { "shell": "[ \"$(uname -s)\" = Darwin ] || fc-list :lang=zh 2>/dev/null | grep -q ." },
|
|
99
|
+
"install": { "debian": { "apt": ["fonts-noto-cjk"] } }
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|