tests/test_pdf_skill.py
tests/test_pdf_skill.pyBrowse 21 files
4,745 tokens
17,433 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""End-to-end tests for the pdf skill helper scripts. No network required."""2from __future__ import annotations3 4import json5import os6import subprocess7import sys8from pathlib import Path9 10import pytest11 12SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"13 14 15def run(script: str, *args: str, expect: int = 0) -> subprocess.CompletedProcess:16 env = dict(os.environ, LC_ALL="C", LANG="C", PYTHONIOENCODING="utf-8")17 proc = subprocess.run(18 [sys.executable, str(SCRIPTS / script), *args],19 capture_output=True, text=True, encoding="utf-8", env=env,20 )21 assert proc.returncode == expect, f"{script} {args}: rc={proc.returncode}\n{proc.stderr}"22 return proc23 24 25@pytest.fixture(scope="module")26def workdir(tmp_path_factory) -> Path:27 return tmp_path_factory.mktemp("pdfwork")28 29 30@pytest.fixture(scope="module")31def sample_image(workdir: Path) -> Path:32 from PIL import Image33 img_path = workdir / "sample.png"34 img = Image.new("RGB", (120, 80), (30, 120, 200))35 img.save(img_path)36 return img_path37 38 39@pytest.fixture(scope="module")40def report_pdf(workdir: Path, sample_image: Path) -> Path:41 spec = {42 "title": "Quarterly Example Report",43 "author": "example-author",44 "elements": [45 {"type": "heading", "text": "Quarterly Example Report", "level": 1},46 {"type": "paragraph", "text": "This is the introduction paragraph with a marker UNIQUEMARK42."},47 {"type": "table", "rows": [["Region", "Units"], ["North", "1250"], ["South", "980"]], "header": True},48 {"type": "image", "path": str(sample_image), "width": 200},49 {"type": "pagebreak"},50 {"type": "heading", "text": "Appendix", "level": 2},51 {"type": "paragraph", "text": "Second page content."},52 ],53 }54 spec_path = workdir / "spec.json"55 spec_path.write_text(json.dumps(spec), encoding="utf-8")56 out = workdir / "report.pdf"57 run("pdf_create.py", str(spec_path), "-o", str(out))58 assert out.exists() and out.stat().st_size > 50059 return out60 61 62def test_create_and_meta(report_pdf: Path):63 meta = json.loads(run("pdf_read.py", str(report_pdf), "--meta").stdout)64 assert meta["page_count"] == 265 assert meta["encrypted"] is False66 assert meta["likely_scanned_pages"] == []67 assert "Quarterly Example Report" in meta["metadata"].get("Title", "")68 69 70def test_extract_text(report_pdf: Path):71 data = json.loads(run("pdf_read.py", str(report_pdf), "--text").stdout)72 assert data["page_count"] == 273 assert "UNIQUEMARK42" in data["pages"][0]74 assert "Appendix" in data["pages"][1]75 assert "Page 1" in data["pages"][0] # page number footer76 77 78def test_extract_tables(report_pdf: Path, workdir: Path):79 csv_dir = workdir / "csvs"80 data = json.loads(run("pdf_read.py", str(report_pdf), "--tables",81 "--csv-dir", str(csv_dir)).stdout)82 assert data["table_count"] >= 183 rows = data["tables"][0]["rows"]84 assert rows[0] == ["Region", "Units"]85 assert ["North", "1250"] in rows86 csv_files = list(csv_dir.glob("*.csv"))87 assert csv_files and "Region" in csv_files[0].read_text(encoding="utf-8")88 89 90@pytest.fixture(scope="module")91def form_pdf(workdir: Path) -> Path:92 from reportlab.lib.pagesizes import A493 from reportlab.pdfgen import canvas94 out = workdir / "form.pdf"95 c = canvas.Canvas(str(out), pagesize=A4)96 form = c.acroForm97 c.drawString(72, 760, "Example Form")98 form.textfield(name="surname", x=72, y=700, width=300, height=20, value="")99 form.checkbox(name="agree", x=72, y=660, buttonStyle="check")100 form.radio(name="color", value="red", x=72, y=620, selected=False)101 form.radio(name="color", value="blue", x=110, y=620, selected=True)102 form.choice(name="size", x=72, y=580, width=120, height=20,103 options=["small", "large"], value="small")104 c.save()105 return out106 107 108def test_form_fill_unicode_roundtrip(form_pdf: Path, workdir: Path):109 surname = "Фамилия — ‘test’"110 values = {"surname": surname, "agree": True, "color": "/red", "size": "large"}111 fields_json = workdir / "values.json"112 fields_json.write_text(json.dumps(values, ensure_ascii=False), encoding="utf-8")113 filled = workdir / "filled.pdf"114 run("pdf_fill_form.py", str(form_pdf), "--fields-json", str(fields_json),115 "-o", str(filled))116 data = json.loads(run("pdf_read.py", str(filled), "--fields").stdout)117 fields = data["fields"]118 assert fields["surname"]["value"] == surname119 assert fields["agree"]["value"] in ("/Yes", "/On", "True", "/1")120 assert fields["color"]["value"] == "/red"121 assert fields["size"]["value"] == "large"122 123 124def test_merge_split_rotate(report_pdf: Path, workdir: Path):125 merged = workdir / "merged.pdf"126 out = json.loads(run("pdf_merge.py", str(report_pdf), str(report_pdf),127 "-o", str(merged), "--bookmarks").stdout)128 assert out["page_count"] == 4129 130 part = workdir / "part.pdf"131 out = json.loads(run("pdf_split.py", str(merged), "--pages", "2-3",132 "--rotate", "90", "-o", str(part)).stdout)133 assert out["page_count"] == 2134 meta = json.loads(run("pdf_read.py", str(part), "--meta").stdout)135 assert meta["page_count"] == 2136 assert all(p["rotation"] % 360 == 90 for p in meta["pages"])137 138 139def test_watermark(report_pdf: Path, workdir: Path):140 # Build the stamp at mid-page so its text does not overlap existing141 # headings (overlapping glyphs confuse text extraction).142 from reportlab.lib.pagesizes import A4143 from reportlab.pdfgen import canvas144 stamp = workdir / "stamp.pdf"145 c = canvas.Canvas(str(stamp), pagesize=A4)146 c.setFont("Helvetica", 40)147 c.drawString(200, 400, "DRAFT")148 c.save()149 stamped = workdir / "stamped.pdf"150 run("pdf_watermark.py", str(report_pdf), "--stamp", str(stamp), "-o", str(stamped))151 data = json.loads(run("pdf_read.py", str(stamped), "--text").stdout)152 assert all("DRAFT" in page for page in data["pages"])153 154 155def test_encrypt_decrypt_roundtrip(report_pdf: Path, workdir: Path):156 enc = workdir / "enc.pdf"157 run("pdf_secure.py", str(report_pdf), "--encrypt", "-o", str(enc),158 "--user-password", "your-password")159 meta = json.loads(run("pdf_read.py", str(enc), "--meta").stdout)160 assert meta["encrypted"] is True161 162 dec = workdir / "dec.pdf"163 run("pdf_secure.py", str(enc), "--decrypt", "-o", str(dec),164 "--password", "your-password")165 data = json.loads(run("pdf_read.py", str(dec), "--text").stdout)166 assert "UNIQUEMARK42" in data["pages"][0]167 168 169def test_compress(report_pdf: Path, workdir: Path):170 out = workdir / "compressed.pdf"171 run("pdf_split.py", str(report_pdf), "--pages", "1-2", "--compress", "-o", str(out))172 data = json.loads(run("pdf_read.py", str(out), "--text").stdout)173 assert "UNIQUEMARK42" in data["pages"][0]174 175 176# ---------------------------------------------------------------- form creation177 178FORM_SPEC = {179 "title": "Example Intake Form",180 "page_size": "A4",181 "fields": [182 {"name": "surname", "type": "text", "page": 1, "label": "Surname",183 "label_box": [72, 700, 150, 714], "entry_box": [160, 696, 400, 716]},184 {"name": "agree", "type": "checkbox", "page": 1, "label": "I agree",185 "label_box": [72, 660, 150, 674], "entry_box": [160, 658, 176, 674]},186 {"name": "color", "type": "radio", "page": 1, "label": "Color",187 "label_box": [72, 620, 150, 634], "entry_box": [160, 616, 400, 636],188 "options": ["red", "blue"], "value": "blue"},189 {"name": "size", "type": "dropdown", "page": 1, "label": "Size",190 "label_box": [72, 580, 150, 594], "entry_box": [160, 576, 300, 596],191 "options": ["small", "large"], "value": "small"},192 ],193}194 195 196@pytest.fixture(scope="module")197def built_form(workdir: Path) -> Path:198 spec_path = workdir / "formspec.json"199 spec_path.write_text(json.dumps(FORM_SPEC), encoding="utf-8")200 out = workdir / "built_form.pdf"201 result = json.loads(run("pdf_make_form.py", str(spec_path), "-o", str(out)).stdout)202 assert len(result["fields"]) == 4203 return out204 205 206def test_make_form_lists_all_fields(built_form: Path):207 data = json.loads(run("pdf_read.py", str(built_form), "--fields").stdout)208 fields = data["fields"]209 assert set(fields) == {"surname", "agree", "color", "size"}210 assert fields["surname"]["type"] == "text"211 assert fields["agree"]["options"] == ["/Off", "/Yes"]212 assert fields["color"]["value"] == "/blue" # pre-selected radio213 assert set(fields["size"]["options"]) == {"small", "large"}214 # label text is drawn on the page, not just stored in the widget215 text = json.loads(run("pdf_read.py", str(built_form), "--text").stdout)216 assert "Surname" in text["pages"][0] and "Size" in text["pages"][0]217 218 219def test_make_form_fill_roundtrip(built_form: Path, workdir: Path):220 values = {"surname": "Smith", "agree": True, "color": "/red", "size": "large"}221 vals = workdir / "builtvals.json"222 vals.write_text(json.dumps(values), encoding="utf-8")223 filled = workdir / "built_filled.pdf"224 run("pdf_fill_form.py", str(built_form), "--fields-json", str(vals), "-o", str(filled))225 fields = json.loads(run("pdf_read.py", str(filled), "--fields").stdout)["fields"]226 assert fields["surname"]["value"] == "Smith"227 assert fields["agree"]["value"] == "/Yes"228 assert fields["color"]["value"] == "/red"229 assert fields["size"]["value"] == "large"230 231 232# ------------------------------------------------------------- layout validation233 234def test_form_layout_valid_spec(workdir: Path):235 spec_path = workdir / "layout_ok.json"236 spec_path.write_text(json.dumps(FORM_SPEC), encoding="utf-8")237 report = json.loads(run("pdf_form_layout.py", str(spec_path)).stdout)238 assert report["ok"] is True239 assert report["errors"] == 0240 assert all(f["ok"] for f in report["fields"])241 242 243def test_form_layout_detects_problems(workdir: Path):244 bad = {245 "page_size": "A4",246 "fields": [247 # out of bounds (x1 beyond A4 width)248 {"name": "wide", "type": "text", "page": 1, "label": "Wide",249 "label_box": [10, 700, 60, 714], "entry_box": [70, 696, 900, 716]},250 # two overlapping entry boxes251 {"name": "one", "type": "text", "page": 1, "label": "One",252 "label_box": [10, 600, 60, 614], "entry_box": [70, 596, 300, 616]},253 {"name": "two", "type": "text", "page": 1, "label": "Two",254 "label_box": [10, 560, 60, 574], "entry_box": [200, 600, 400, 620]},255 # label far away from its entry256 {"name": "lost", "type": "text", "page": 1, "label": "Lost",257 "label_box": [10, 100, 60, 114], "entry_box": [400, 500, 500, 520]},258 # too small259 {"name": "tiny", "type": "text", "page": 1,260 "entry_box": [10, 50, 14, 54]},261 ],262 }263 spec_path = workdir / "layout_bad.json"264 spec_path.write_text(json.dumps(bad), encoding="utf-8")265 proc = run("pdf_form_layout.py", str(spec_path), expect=1)266 report = json.loads(proc.stdout)267 assert report["ok"] is False268 by_name = {f["name"]: f for f in report["fields"]}269 assert any("bounds" in p for p in by_name["wide"]["problems"])270 assert any("overlaps field" in p for p in by_name["two"]["problems"])271 assert any("from its entry" in p for p in by_name["lost"]["problems"])272 assert any("minimum size" in p for p in by_name["tiny"]["problems"])273 assert by_name["one"]["ok"] # first of the overlapping pair reports clean274 275 276# ------------------------------------------------- rasterization (overlay, pages)277 278def _raster_available() -> bool:279 import shutil280 try:281 import pypdfium2 # noqa: F401282 return True283 except ImportError:284 return shutil.which("pdftoppm") is not None285 286 287def test_form_layout_overlay(built_form: Path, workdir: Path):288 spec_path = workdir / "formspec.json"289 out_png = workdir / "overlay.png"290 report = json.loads(run("pdf_form_layout.py", str(spec_path), "--pdf", str(built_form),291 "--render-overlay", str(out_png)).stdout)292 overlay = report["overlay"]293 if _raster_available():294 assert overlay["rendered"] is True295 assert out_png.exists() and out_png.stat().st_size > 1000296 from PIL import Image297 with Image.open(out_png) as img:298 assert img.width > 100 and img.height > 100299 else:300 assert overlay["rendered"] is False301 assert overlay["missing"] # install hints present302 303 304def test_form_layout_overlay_blank_page(workdir: Path):305 # No --pdf: overlay is drawn on a blank page, PIL-only, always renders.306 spec_path = workdir / "formspec.json"307 out_png = workdir / "overlay_blank.png"308 report = json.loads(run("pdf_form_layout.py", str(spec_path),309 "--render-overlay", str(out_png)).stdout)310 assert report["overlay"]["rendered"] is True311 assert out_png.exists()312 313 314def test_page_image_export(report_pdf: Path, workdir: Path):315 out_dir = workdir / "pageimgs"316 result = json.loads(run("pdf_page_image.py", str(report_pdf), "--pages", "1-2",317 "--dpi", "72", "--out-dir", str(out_dir)).stdout)318 if _raster_available():319 assert result["rendered"] is True320 assert len(result["files"]) == 2321 from PIL import Image322 with Image.open(result["files"][0]) as img:323 # A4 at 72 dpi is ~595x842 px324 assert 500 < img.width < 700325 else:326 assert result["rendered"] is False327 assert result["missing"]328 329 330def test_page_image_bad_range(report_pdf: Path, workdir: Path):331 if not _raster_available():332 pytest.skip("no rasterizer available")333 run("pdf_page_image.py", str(report_pdf), "--pages", "9",334 "--out-dir", str(workdir / "nope"), expect=4)335 336 337# --------------------------------------------------------------------- stamping338 339def test_stamp_text(report_pdf: Path, workdir: Path):340 out = workdir / "stamp_text.pdf"341 run("pdf_stamp.py", str(report_pdf), "-o", str(out),342 "--text", "STAMPMARK77", "--x", "150", "--y", "500",343 "--font-size", "30", "--color", "#cc0000", "--pages", "1")344 data = json.loads(run("pdf_read.py", str(out), "--text").stdout)345 assert "STAMPMARK77" in data["pages"][0]346 assert "STAMPMARK77" not in data["pages"][1] # only page 1 stamped347 348 349def test_stamp_text_rotated_opacity(report_pdf: Path, workdir: Path):350 out = workdir / "stamp_rot.pdf"351 run("pdf_stamp.py", str(report_pdf), "-o", str(out),352 "--text", "DRAFT", "--x", "150", "--y", "400", "--font-size", "60",353 "--rotation", "45", "--opacity", "0.3")354 # Rotated glyphs confuse pdfplumber's line grouping; verify via pypdf.355 from pypdf import PdfReader356 text = PdfReader(str(out)).pages[0].extract_text()357 assert "DRAFT" in text358 359 360def test_stamp_image(report_pdf: Path, sample_image: Path, workdir: Path):361 out = workdir / "stamp_img.pdf"362 run("pdf_stamp.py", str(report_pdf), "-o", str(out),363 "--image", str(sample_image), "--x", "400", "--y", "60",364 "--width", "100", "--pages", "2")365 from pypdf import PdfReader366 before = PdfReader(str(report_pdf))367 after = PdfReader(str(out))368 369 def image_xobjects(page):370 res = page.get("/Resources", {})371 xo = res.get("/XObject")372 if xo is None:373 return 0374 return sum(1 for k in xo if xo[k].get("/Subtype") == "/Image")375 376 assert image_xobjects(after.pages[1]) > image_xobjects(before.pages[1])377 assert image_xobjects(after.pages[0]) == image_xobjects(before.pages[0])378 379 380# --------------------------------------------------------- metadata + attachments381 382def test_meta_set_and_clear(report_pdf: Path, workdir: Path):383 out = workdir / "meta_set.pdf"384 run("pdf_meta.py", str(report_pdf), "--set-meta", "-o", str(out),385 "--title", "Retitled Example", "--author", "example-author",386 "--subject", "Testing", "--keywords", "alpha, beta")387 meta = json.loads(run("pdf_read.py", str(out), "--meta").stdout)["metadata"]388 assert meta["Title"] == "Retitled Example"389 assert meta["Author"] == "example-author"390 assert meta["Subject"] == "Testing"391 assert meta["Keywords"] == "alpha, beta"392 393 cleared = workdir / "meta_clear.pdf"394 run("pdf_meta.py", str(out), "--clear-meta", "-o", str(cleared))395 meta = json.loads(run("pdf_read.py", str(cleared), "--meta").stdout)["metadata"]396 assert "Title" not in meta or meta.get("Title") in ("", None)397 398 399def test_attachments_roundtrip(report_pdf: Path, workdir: Path):400 payload = workdir / "payload.txt"401 payload.write_text("attachment payload UNIQUEATTACH99\n", encoding="utf-8")402 with_att = workdir / "with_att.pdf"403 run("pdf_meta.py", str(report_pdf), "--attach", str(payload), "-o", str(with_att))404 405 listing = json.loads(run("pdf_meta.py", str(with_att), "--list-attachments").stdout)406 assert listing["attachment_count"] == 1407 assert listing["attachments"] == ["payload.txt"]408 409 ext_dir = workdir / "extracted"410 result = json.loads(run("pdf_meta.py", str(with_att),411 "--extract-attachments", str(ext_dir)).stdout)412 assert len(result["extracted"]) == 1413 extracted = Path(result["extracted"][0])414 assert "UNIQUEATTACH99" in extracted.read_text(encoding="utf-8")415