Files
2026-07-13 12:59:42 +08:00

1571 lines
66 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
CI verification script for opendataloader-pdf CLI options.
Three-level verification: Smoke / Content assertion / Comparison.
"""
import glob
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
# Force stdout/stderr to UTF-8 so this script does not crash when JAR output
# contains characters outside the system locale (e.g. cp949 on Korean Windows).
# Linux CI runners use UTF-8 by default; this is a no-op there.
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
# ---------------------------------------------------------------------------
# Repository root (two levels up from this file: verification/ci-verify.py)
# ---------------------------------------------------------------------------
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ---------------------------------------------------------------------------
# PDF Fixtures
# ---------------------------------------------------------------------------
PDF_BASIC = os.path.join(REPO_ROOT, "samples/pdf/lorem.pdf")
PDF_MULTIPAGE = os.path.join(REPO_ROOT, "samples/pdf/2408.02509v1.pdf")
PDF_IMAGES = os.path.join(REPO_ROOT, "samples/pdf/pdfua-1-reference-suite-1-1/PDFUA-Ref-2-06_Brochure.pdf")
PDF_TABLES = os.path.join(REPO_ROOT, "samples/pdf/issue-336-conto-economico-bialetti.pdf")
PDF_STRUCTURED = os.path.join(REPO_ROOT, "samples/pdf/pdfua-1-reference-suite-1-1/PDFUA-Ref-2-08_BookChapter.pdf")
PDF_PASSWORD = os.path.join(REPO_ROOT, "samples/pdf/password-protected.pdf")
PDF_STRIKETHROUGH = os.path.join(REPO_ROOT, "samples/pdf/strikethrough.pdf")
# strikethrough.pdf carries a /StrikeOut markup annotation, which the current
# line-based detection does not see. Native annotation support is in progress
# upstream (veraPDF isStrikethroughText property):
# https://github.com/opendataloader-project/opendataloader-pdf-tasks/issues/348
# Flip to False to re-enable the check once that lands.
STRIKETHROUGH_KNOWN_ISSUE = True
PDF_INVALID_CHARS = os.path.join(REPO_ROOT, "samples/pdf/invalid-chars.pdf")
PDF_SANITIZE = os.path.join(REPO_ROOT, "samples/pdf/sanitize-target.pdf")
PDF_SAFETY = os.path.join(REPO_ROOT, "samples/pdf/safety-trigger.pdf")
PDF_FAKE_JPG = os.path.join(REPO_ROOT, "samples/pdf/fake-jpg.pdf")
PDF_EMPTY = os.path.join(REPO_ROOT, "samples/pdf/empty.pdf")
PNG_FILE = os.path.join(REPO_ROOT, "samples/pdf/abcd.png")
MIXED_DIR = os.path.join(REPO_ROOT, "samples/pdf/mixed")
PDF_SANITIZATION_TARGETS = os.path.join(REPO_ROOT, "samples/pdf/sanitization-targets.pdf")
PASSWORD = "test"
# ---------------------------------------------------------------------------
# Stack-trace leakage markers — must NEVER appear in user-facing output.
# When the CLI catches a Java exception (bad PDF, unreadable file, etc.) it
# is expected to surface a friendly one-line error, not a raw veraPDF or
# java.io stack trace.
# ---------------------------------------------------------------------------
STACK_TRACE_LEAK_MARKERS = (
"org.verapdf",
"java.io.IOException:",
"\tat ",
)
# ---------------------------------------------------------------------------
# Option coverage registries
# ---------------------------------------------------------------------------
COVERED_OPTIONS = {
"--format", "--output-dir", "--quiet", "--to-stdout",
"--password", "--keep-line-breaks", "--replace-invalid-chars",
"--pages", "--include-header-footer", "--detect-strikethrough",
"--sanitize", "--content-safety-off", "--use-struct-tree",
"--table-method", "--reading-order", "--threads",
"--image-output", "--image-format", "--image-dir",
"--markdown-with-html",
"--markdown-page-separator", "--text-page-separator", "--html-page-separator",
}
HYBRID_OPTIONS = {
"--hybrid", "--hybrid-mode", "--hybrid-timeout",
"--hybrid-fallback", "--hybrid-url",
"--hybrid-hancom-ai-regionlist-strategy",
"--hybrid-hancom-ai-ocr-strategy",
"--hybrid-hancom-ai-image-cache",
}
# ---------------------------------------------------------------------------
# Global results accumulator
# ---------------------------------------------------------------------------
RESULTS: list[tuple[str, bool | None]] = []
# ---------------------------------------------------------------------------
# Core helpers
# ---------------------------------------------------------------------------
def run_cli(
args: list[str], capture_output: bool = True, timeout: int = 120
) -> subprocess.CompletedProcess:
"""Run the opendataloader-pdf CLI with given args using sys.executable for portability.
Forces UTF-8 decoding with errors=replace so the runner does not crash on
non-ASCII output under non-UTF-8 system locales (e.g. cp949 on Korean
Windows), where the default text=True decode would set stdout/stderr to
None when the JAR emits a byte the locale cannot represent.
A timeout bounds each invocation so a stuck CLI raises TimeoutExpired
instead of hanging the whole verification job indefinitely.
"""
cmd = [sys.executable, "-m", "opendataloader_pdf"] + args
return subprocess.run(
cmd,
capture_output=capture_output,
text=True,
encoding="utf-8",
errors="replace",
cwd=REPO_ROOT,
timeout=timeout,
)
def record(label: str, passed: bool | None) -> None:
"""Record a test result and print it immediately."""
RESULTS.append((label, passed))
if passed is None:
print(f" SKIP | {label}")
elif passed:
print(f" PASS | {label}")
else:
print(f" FAIL | {label}")
# ---------------------------------------------------------------------------
# Level 1 Smoke
# ---------------------------------------------------------------------------
def smoke(label: str, cmd_args: list[str], outdir: str) -> bool:
"""Level 1: exit code == 0 AND at least one file created in outdir."""
try:
result = run_cli(cmd_args)
if result.returncode != 0:
print(f" [smoke] non-zero exit: {result.returncode}")
print(f" stderr: {result.stderr[:300]}")
return False
files = [f for f in os.listdir(outdir) if os.path.isfile(os.path.join(outdir, f))]
if not files:
print(f" [smoke] no output files created in {outdir}")
return False
return True
except Exception as exc:
print(f" [smoke] exception: {exc}")
return False
# ---------------------------------------------------------------------------
# Level 2 Content assertion
# ---------------------------------------------------------------------------
def assert_content(
label: str,
filepath: str,
must_contain: list[str] | None = None,
must_not_contain: list[str] | None = None,
case_insensitive: bool = False,
) -> bool:
"""Level 2: check text file content for required/forbidden strings."""
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
if case_insensitive:
content_cmp = content.lower()
must_contain_cmp = [s.lower() for s in (must_contain or [])]
must_not_contain_cmp = [s.lower() for s in (must_not_contain or [])]
else:
content_cmp = content
must_contain_cmp = list(must_contain or [])
must_not_contain_cmp = list(must_not_contain or [])
for needle in must_contain_cmp:
if needle not in content_cmp:
print(f" [content] missing required string: {needle!r}")
return False
for needle in must_not_contain_cmp:
if needle in content_cmp:
print(f" [content] found forbidden string: {needle!r}")
return False
# When no string assertions are supplied (None or empty list), fall back
# to a non-empty check so an empty output file never passes vacuously.
if not must_contain and not must_not_contain:
if not content.strip():
print(" [content] file is empty")
return False
return True
except Exception as exc:
print(f" [content] exception: {exc}")
return False
def assert_stderr_empty(label: str, cmd_args: list[str]) -> bool:
"""For --quiet: verify stderr is completely empty."""
try:
result = run_cli(cmd_args)
if result.returncode != 0:
print(f" [stderr] non-zero exit: {result.returncode}")
return False
if result.stderr.strip():
print(f" [stderr] stderr not empty: {result.stderr[:200]!r}")
return False
return True
except Exception as exc:
print(f" [stderr] exception: {exc}")
return False
def assert_stdout_nonempty(label: str, cmd_args: list[str]) -> bool:
"""For --to-stdout: verify stdout is not empty."""
try:
result = run_cli(cmd_args)
if result.returncode != 0:
print(f" [stdout] non-zero exit: {result.returncode}")
return False
if not result.stdout.strip():
print(f" [stdout] stdout is empty")
return False
return True
except Exception as exc:
print(f" [stdout] exception: {exc}")
return False
# ---------------------------------------------------------------------------
# Level 2.5 File size assertion
# ---------------------------------------------------------------------------
def assert_file_size(
label: str,
filepath: str,
smaller_than: int | None = None,
larger_than: int | None = None,
) -> bool:
"""Level 2.5: compare file size against thresholds (bytes)."""
try:
size = os.path.getsize(filepath)
if smaller_than is not None and size >= smaller_than:
print(f" [size] {size} >= {smaller_than} (expected smaller)")
return False
if larger_than is not None and size <= larger_than:
print(f" [size] {size} <= {larger_than} (expected larger)")
return False
return True
except Exception as exc:
print(f" [size] exception: {exc}")
return False
# ---------------------------------------------------------------------------
# Level 3 Comparison
# ---------------------------------------------------------------------------
def _find_first_output_file(outdir: str) -> str | None:
"""Return the path of the first regular file found in outdir, or None."""
for name in sorted(os.listdir(outdir)):
path = os.path.join(outdir, name)
if os.path.isfile(path):
return path
return None
def run_comparison(
label: str,
cmd_baseline: list[str],
cmd_variant: list[str],
expect: str = "differ",
) -> bool:
"""Level 3: compare baseline vs variant output. expect='differ'|'identical'."""
try:
with tempfile.TemporaryDirectory() as base_dir, tempfile.TemporaryDirectory() as var_dir:
res_base = run_cli(cmd_baseline + ["--output-dir", base_dir])
res_var = run_cli(cmd_variant + ["--output-dir", var_dir])
if res_base.returncode != 0:
print(f" [compare] baseline non-zero exit: {res_base.returncode}")
print(f" stderr: {res_base.stderr[:300]}")
return False
if res_var.returncode != 0:
print(f" [compare] variant non-zero exit: {res_var.returncode}")
print(f" stderr: {res_var.stderr[:300]}")
return False
base_file = _find_first_output_file(base_dir)
var_file = _find_first_output_file(var_dir)
if base_file is None or var_file is None:
print(f" [compare] missing output file (base={base_file}, var={var_file})")
return False
with open(base_file, "rb") as fh:
base_content = fh.read()
with open(var_file, "rb") as fh:
var_content = fh.read()
are_same = base_content == var_content
if expect == "differ":
if are_same:
print(f" [compare] outputs are identical but expected to differ")
return False
return True
else: # identical
if not are_same:
print(f" [compare] outputs differ but expected to be identical")
return False
return True
except Exception as exc:
print(f" [compare] exception: {exc}")
return False
# ---------------------------------------------------------------------------
# Option coverage check
# ---------------------------------------------------------------------------
def check_option_coverage() -> None:
"""Read options.json and verify all non-hybrid options are in COVERED_OPTIONS."""
options_path = os.path.join(REPO_ROOT, "options.json")
if not os.path.exists(options_path):
# Fail closed: this is the authoritative option registry for the
# coverage gate. Skipping when it is absent would let CI pass with
# coverage checking silently disabled (e.g. if the path regresses).
print(f"ERROR: options.json not found at {options_path}; cannot verify option coverage")
sys.exit(1)
with open(options_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
uncovered: list[str] = []
for opt in data.get("options", []):
flag = "--" + opt["name"]
if flag in HYBRID_OPTIONS:
continue
if flag not in COVERED_OPTIONS:
uncovered.append(flag)
if uncovered:
print("ERROR: The following options are not covered by COVERED_OPTIONS:")
for flag in uncovered:
print(f" {flag}")
sys.exit(1)
else:
print(f"Option coverage check passed ({len(COVERED_OPTIONS)} options covered).")
# ---------------------------------------------------------------------------
# Helper: find output file by extension in a directory
# ---------------------------------------------------------------------------
def _find_file_by_ext(directory: str, ext: str) -> str | None:
"""Return the first file with the given extension (e.g. '.md') in directory."""
pattern = os.path.join(directory, f"*{ext}")
matches = glob.glob(pattern)
return matches[0] if matches else None
def _find_image_files(directory: str) -> list[str]:
"""Return all image files (.png, .jpg, .jpeg) in directory (recursive)."""
results = []
for root, _dirs, files in os.walk(directory):
for name in files:
if name.lower().endswith((".png", ".jpg", ".jpeg")):
results.append(os.path.join(root, name))
return results
# ---------------------------------------------------------------------------
# Encoding safety helpers
# ---------------------------------------------------------------------------
def _verify_help_cp949_safe(command: list[str], required: bool = False) -> bool | None:
"""Run `command --help` with PYTHONIOENCODING=cp949 and check for UnicodeEncodeError.
Returns True (PASS), False (FAIL — UnicodeEncodeError detected), or None (SKIP).
Korean Windows console uses cp949; non-ASCII chars (em-dash etc.) in help text
crash argparse output when stdout codec cannot encode them.
When ``required`` is True the CLI is known to be installed, so a hang
(TimeoutExpired) or any execution failure is a real regression and is
reported as FAIL rather than swallowed into a SKIP — otherwise a broken
--help would pass the release gate unnoticed.
"""
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp949"
try:
result = subprocess.run(
command + ["--help"],
env=env,
capture_output=True,
timeout=60,
)
except Exception as exc:
print(f" [cp949 help] exception: {exc}")
return False if required else None
stderr_text = result.stderr.decode("utf-8", errors="replace")
stdout_text = result.stdout.decode("utf-8", errors="replace")
if "UnicodeEncodeError" in stderr_text or "UnicodeEncodeError" in stdout_text:
print(" [cp949 help] UnicodeEncodeError detected in --help output")
return False
if result.returncode != 0:
print(f" [cp949 help] non-zero exit: {result.returncode}")
return False
return True
def _verify_hybrid_help_cp949_safe() -> bool | None:
"""Test hybrid CLI --help under cp949. SKIP if [hybrid] extras not installed."""
if shutil.which("opendataloader-pdf-hybrid") is None:
print(" [cp949 help] hybrid CLI not installed, skipping")
return None
# Pre-check: does hybrid CLI bail out at dependency check?
pre = subprocess.run(
[sys.executable, "-c",
"from opendataloader_pdf.hybrid_server import _check_dependencies; _check_dependencies()"],
capture_output=True,
timeout=15,
)
if pre.returncode != 0:
stderr = pre.stderr.decode("utf-8", errors="replace")
if "Missing dependencies" in stderr or "ImportError" in stderr:
print(" [cp949 help] hybrid deps missing, skipping")
return None
# Availability is confirmed above; from here a failure is a real regression.
return _verify_help_cp949_safe(
[sys.executable, "-m", "opendataloader_pdf.hybrid_server"], required=True
)
# ---------------------------------------------------------------------------
# Hybrid mode behavior — fail-fast contract
#
# Hybrid mode delegates page processing to an external backend server. When
# any backend page is left unprocessed and the user did not opt in to Java
# fallback, the CLI must surface that as a non-zero exit code — otherwise
# automation (subprocess.run, CI exit-code branches, library wrappers) reads
# the silent partial result as success.
#
# Note on coverage scope: --hybrid-fallback applies only to chunk-level
# processing failures, not to the initial backend availability check (which
# is intentionally unconditional — see HybridDocumentProcessor Phase 0).
# Verifying the fallback contract therefore requires a reachable backend
# that emits chunk-level failures, which is out of scope for this runner
# (no mock backend infrastructure). The unreachable-URL path below exercises
# the fail-fast contract that callers depend on most.
# ---------------------------------------------------------------------------
# Reserved port that never accepts connections — used to simulate a
# guaranteed-unreachable hybrid backend.
UNREACHABLE_HYBRID_URL = "http://127.0.0.1:1"
def _verify_hybrid_fail_fast_on_unreachable_backend() -> bool | None:
"""
Contract: when the hybrid backend is unreachable, the CLI must exit with
a non-zero code. Anything else means backend failures are silently
dropped, leaving callers unable to detect that processing did not
actually happen.
"""
if shutil.which("opendataloader-pdf-hybrid") is None:
print(" [hybrid fail-fast] hybrid CLI not installed, skipping")
return None
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_BASIC,
"--hybrid", "docling-fast",
"--hybrid-url", UNREACHABLE_HYBRID_URL,
"--output-dir", tmpdir,
])
if result.returncode == 0:
files = [f for f in os.listdir(tmpdir)
if os.path.isfile(os.path.join(tmpdir, f))]
print(f" [hybrid fail-fast] expected non-zero exit, got 0 (files: {files})")
print(f" stderr: {result.stderr[:300]}")
return False
return True
# ---------------------------------------------------------------------------
# Stack-trace leakage helper
# ---------------------------------------------------------------------------
def _has_stack_trace_leak(text: str | None) -> str | None:
"""Return the first leak marker found in text, or None if clean / empty."""
if not text:
return None
for marker in STACK_TRACE_LEAK_MARKERS:
if marker in text:
return marker
return None
def _assert_no_leak(label: str, stdout: str | None, stderr: str | None) -> bool:
"""Verify neither stdout nor stderr leak a Java stack trace."""
for stream_name, text in (("stdout", stdout), ("stderr", stderr)):
leak = _has_stack_trace_leak(text)
if leak is not None:
print(f" [{label}] stack-trace leak on {stream_name}: {leak!r}")
return False
return True
# ---------------------------------------------------------------------------
# Input validation (malformed and non-PDF inputs)
#
# These scenarios are separate from CLI option effects: they cover the
# input-rejection contract for malformed PDFs and non-PDF inputs. The core
# invariant is that NO stack trace ever reaches the user — the CLI must
# surface a one-line friendly error instead.
# ---------------------------------------------------------------------------
def verify_input_validation() -> None:
print("\n--- input validation ---")
# 1. .pdf-named JPEG content as top-level CLI argument.
if not os.path.exists(PDF_FAKE_JPG):
record("fake-jpg.pdf top-level [SKIPPED: fixture missing]", None)
else:
result = run_cli([PDF_FAKE_JPG])
ok = result.returncode != 0
if ok and "is not a valid PDF file" not in result.stdout \
and "missing %PDF" not in result.stdout:
print(f" [fake-jpg] expected friendly error on stdout, got: "
f"{result.stdout[:200]!r}")
ok = False
if ok:
ok = _assert_no_leak("fake-jpg", result.stdout, result.stderr)
record("fake-jpg.pdf top-level → friendly error + no leak", ok)
# 2. Zero-byte .pdf as top-level CLI argument.
if not os.path.exists(PDF_EMPTY):
record("empty.pdf top-level [SKIPPED: fixture missing]", None)
else:
result = run_cli([PDF_EMPTY])
ok = result.returncode != 0
if ok:
ok = _assert_no_leak("empty.pdf", result.stdout, result.stderr)
record("empty.pdf top-level → non-zero + no leak", ok)
# 3. .png file as top-level CLI argument.
if not os.path.exists(PNG_FILE):
record("abcd.png top-level [SKIPPED: fixture missing]", None)
else:
result = run_cli([PNG_FILE])
ok = result.returncode != 0
if ok and "is not a PDF file" not in result.stdout \
and "not a PDF" not in result.stdout:
print(f" [abcd.png] expected 'not a PDF' on stdout, got: "
f"{result.stdout[:200]!r}")
ok = False
if ok:
ok = _assert_no_leak("abcd.png", result.stdout, result.stderr)
record("abcd.png top-level → friendly error + no leak", ok)
# 4. Folder containing one PDF + one PNG.
# Contract: PDF processed, PNG silently skipped, exit 0, no stack trace.
if not os.path.exists(MIXED_DIR):
record("mixed/ folder [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as outdir:
result = run_cli([MIXED_DIR, "--output-dir", outdir])
ok = result.returncode == 0
if ok:
ok = _assert_no_leak("mixed folder", result.stdout, result.stderr)
if ok:
produced = [f for f in os.listdir(outdir)
if os.path.isfile(os.path.join(outdir, f))]
if not produced:
print(" [mixed folder] PDF was not processed (no output)")
ok = False
record("mixed/ folder → silent skip + PDF processed", ok)
# 5. Non-existent file → non-zero exit, no leak.
missing_path = os.path.join(REPO_ROOT, "samples/pdf/__missing_fixture__.pdf")
result = run_cli([missing_path])
ok = result.returncode != 0 and _assert_no_leak(
"missing file", result.stdout, result.stderr
)
record("missing .pdf → non-zero exit + no leak", ok)
# 6. No arguments → non-zero exit with help-style output on stderr.
# Python argparse convention: missing required positional → exit 2, usage
# on stderr. We assert non-zero (not a specific code) and that the help
# text identifies itself as a usage message.
result = run_cli([])
stderr_text = (result.stderr or "").lower()
stdout_text = (result.stdout or "").strip()
ok = result.returncode != 0 and "usage" in stderr_text and not stdout_text
record("no args → non-zero exit + usage on stderr", ok)
# 7. Invalid option → non-zero exit (POSIX convention is 2; we only
# enforce non-zero to avoid coupling to a specific code).
result = run_cli(["--no-such-option-xyz"])
ok = result.returncode != 0
record("invalid option → non-zero exit", ok)
# ---------------------------------------------------------------------------
# Folder summary line
#
# After traversing a folder argument, the CLI must print exactly one summary
# line per top-level argument — "No PDF files found in 'X'" or "Processed N
# PDF file(s) in 'X'". The summary is a *result*, not a log, so --quiet must
# NOT suppress it.
# ---------------------------------------------------------------------------
def verify_folder_summary() -> None:
print("\n--- folder summary ---")
# Empty folder → "No PDF files found"
with tempfile.TemporaryDirectory() as folder, \
tempfile.TemporaryDirectory() as outdir:
result = run_cli([folder, "--output-dir", outdir])
ok = result.returncode == 0 and "No PDF files found" in result.stdout
if not ok:
print(f" [empty folder] stdout: {result.stdout[:200]!r}")
record("empty folder → 'No PDF files found' + exit 0", ok)
# Folder with 1 PDF → "Processed 1 PDF file"
if not os.path.exists(PDF_BASIC):
record("folder with 1 PDF [SKIPPED: PDF_BASIC missing]", None)
record("folder with 3 PDFs [SKIPPED: PDF_BASIC missing]", None)
else:
with tempfile.TemporaryDirectory() as folder, \
tempfile.TemporaryDirectory() as outdir:
shutil.copy(PDF_BASIC, os.path.join(folder, "doc.pdf"))
result = run_cli([folder, "--output-dir", outdir])
ok = result.returncode == 0 and "Processed 1 PDF file" in result.stdout
if not ok:
print(f" [1-pdf folder] stdout: {result.stdout[:200]!r}")
record("folder with 1 PDF → 'Processed 1 PDF file'", ok)
with tempfile.TemporaryDirectory() as folder, \
tempfile.TemporaryDirectory() as outdir:
for name in ("a.pdf", "b.pdf", "c.pdf"):
shutil.copy(PDF_BASIC, os.path.join(folder, name))
result = run_cli([folder, "--output-dir", outdir])
ok = result.returncode == 0 and "Processed 3 PDF file" in result.stdout
if not ok:
print(f" [3-pdf folder] stdout: {result.stdout[:200]!r}")
record("folder with 3 PDFs → 'Processed 3 PDF file(s)'", ok)
# --quiet must NOT suppress the summary line (result, not log).
with tempfile.TemporaryDirectory() as folder, \
tempfile.TemporaryDirectory() as outdir:
result = run_cli([folder, "--quiet", "--output-dir", outdir])
ok = result.returncode == 0 and "No PDF files found" in result.stdout
if not ok:
print(f" [--quiet folder summary] stdout: {result.stdout[:200]!r}")
record("--quiet preserves folder summary line", ok)
# ---------------------------------------------------------------------------
# --markdown-with-html
#
# --markdown-with-html is the first-class flag for HTML-in-Markdown output;
# --format markdown-with-html / --format markdown-with-images remain accepted
# values for one release but must emit a deprecation warning to stderr. After
# the next major release these tests can be inverted (deprecated values
# become hard errors).
# ---------------------------------------------------------------------------
def verify_markdown_with_html() -> None:
print("\n--- --markdown-with-html ---")
if not os.path.exists(PDF_TABLES):
record("--markdown-with-html [SKIPPED: fixture missing]", None)
record("--format markdown-with-html (deprecated) [SKIPPED]", None)
record("--format markdown-with-images (deprecated) [SKIPPED]", None)
return
# First-class flag: --format markdown + --markdown-with-html.
with tempfile.TemporaryDirectory() as outdir:
result = run_cli([
PDF_TABLES,
"--format", "markdown",
"--markdown-with-html",
"--output-dir", outdir,
])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(outdir, ".md")
ok = md_file is not None and assert_content(
"--markdown-with-html", md_file,
must_contain=["<table"],
case_insensitive=True,
)
record("--markdown-with-html (first-class flag)", ok)
# Deprecated value: --format markdown-with-html must still produce
# HTML-in-markdown output AND emit a deprecation warning on stderr.
with tempfile.TemporaryDirectory() as outdir:
result = run_cli([
PDF_TABLES,
"--format", "markdown-with-html",
"--output-dir", outdir,
])
ok = result.returncode == 0
# The wrapper relays JAR stderr to stdout in streaming mode, so the
# warning may land on either stream — check both.
combined = (result.stdout + result.stderr).lower()
if ok and "deprecat" not in combined:
print(f" [deprecated markdown-with-html] no deprecation warning "
f"on stdout/stderr: {(result.stdout + result.stderr)[:200]!r}")
ok = False
if ok:
md_file = _find_file_by_ext(outdir, ".md")
ok = md_file is not None and assert_content(
"deprecated markdown-with-html", md_file,
must_contain=["<table"],
case_insensitive=True,
)
record("--format markdown-with-html (deprecated, BC + warning)", ok)
# Deprecated value: --format markdown-with-images must still succeed
# and emit a deprecation warning. Output contents are not asserted —
# the contract here is BC + warning, not equivalence.
with tempfile.TemporaryDirectory() as outdir:
result = run_cli([
PDF_BASIC,
"--format", "markdown-with-images",
"--output-dir", outdir,
])
ok = result.returncode == 0
combined = (result.stdout + result.stderr).lower()
if ok and "deprecat" not in combined:
print(f" [deprecated markdown-with-images] no deprecation "
f"warning: {(result.stdout + result.stderr)[:200]!r}")
ok = False
record("--format markdown-with-images (deprecated, BC + warning)", ok)
# ---------------------------------------------------------------------------
# Sanitization rules spot check
#
# Each default rule gets a representative trigger string. The contract per
# rule is:
# - baseline (no --sanitize) extracts the trigger verbatim
# - --sanitize variant strips the trigger and inserts the replacement
# If a rule regresses, exactly that label fails — making the regression
# easy to attribute. SCENARIOS list also acts as living documentation of
# what the default rule set covers.
#
# Triggers are chosen so each matches exactly one rule (no overlap), and
# replacements reflect the rule that actually fires. Rules whose regex is
# fully subsumed by an earlier rule (e.g. 15-digit caught by 10-18 digit,
# MAC caught by IPv6) are intentionally omitted — they are dead in the
# current rule order and listing them would falsely claim coverage.
# ---------------------------------------------------------------------------
SANITIZATION_SCENARIOS: list[tuple[str, str, str]] = [
# (label, trigger in source PDF, expected replacement after --sanitize)
("email", "alice.smith@example.org", "email@example.com"),
("international phone", "+82-10-1234-5678", "+00-0000-0000"),
("license/passport", "AB1234567", "AA0000000"),
("credit card", "1234-5678-9012-3456", "0000-0000-0000-0000"),
("10-18 digit string", "1234567890", "0000000000000000"),
("IPv4", "192.168.0.42", "0.0.0.0"),
("IPv6", "2001:db8:1234:5678:90ab:cdef:0:1", "0.0.0.0::1"),
("URL", "https://github.com/owner/repo", "https://example.com"),
]
def verify_sanitization_rules() -> None:
print("\n--- sanitization rule spot check ---")
if not os.path.exists(PDF_SANITIZATION_TARGETS):
for label, _trigger, _replacement in SANITIZATION_SCENARIOS:
record(f"--sanitize {label} [SKIPPED: fixture missing]", None)
return
with tempfile.TemporaryDirectory() as base_dir, \
tempfile.TemporaryDirectory() as san_dir:
base = run_cli([PDF_SANITIZATION_TARGETS,
"--format", "text", "--output-dir", base_dir])
san = run_cli([PDF_SANITIZATION_TARGETS,
"--format", "text", "--sanitize", "--output-dir", san_dir])
if base.returncode != 0 or san.returncode != 0:
for label, _trigger, _replacement in SANITIZATION_SCENARIOS:
record(f"--sanitize {label} [CLI run failed]", False)
return
base_file = _find_file_by_ext(base_dir, ".txt")
san_file = _find_file_by_ext(san_dir, ".txt")
if not (base_file and san_file):
for label, _trigger, _replacement in SANITIZATION_SCENARIOS:
record(f"--sanitize {label} [missing output file]", False)
return
with open(base_file, encoding="utf-8", errors="replace") as fh:
base_text = fh.read()
with open(san_file, encoding="utf-8", errors="replace") as fh:
san_text = fh.read()
for label, trigger, replacement in SANITIZATION_SCENARIOS:
if trigger not in base_text:
# Fixture self-check: if baseline lacks the trigger, the
# fixture is broken — not the rule. Report explicitly so
# we don't blame the wrong layer.
print(f" [{label}] trigger not in baseline text — "
f"fixture problem, not a sanitization regression. "
f"trigger={trigger!r}")
record(f"--sanitize {label} [fixture: trigger missing in baseline]", False)
continue
if trigger in san_text:
print(f" [{label}] trigger survived --sanitize: {trigger!r}")
record(f"--sanitize {label}", False)
continue
if replacement not in san_text:
print(f" [{label}] expected replacement not found: "
f"{replacement!r}")
record(f"--sanitize {label}", False)
continue
record(f"--sanitize {label}", True)
# ---------------------------------------------------------------------------
# Main test suite
# ---------------------------------------------------------------------------
def main() -> None:
check_option_coverage()
print("=" * 60)
print("opendataloader-pdf CLI Verification")
print("=" * 60)
# ------------------------------------------------------------------
# --format markdown
# ------------------------------------------------------------------
print("\n--- --format ---")
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_BASIC, "--format", "markdown", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None and assert_content(
"--format markdown", md_file, must_contain=None
)
if ok:
# Require a real ATX heading line ("# ..."), which is a
# structural markdown signal. A bare "*" (the old check) occurs
# in almost any prose, so it passed even on non-markdown output.
with open(md_file, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
ok = bool(re.search(r"(?m)^#{1,6} ", content))
if not ok:
print(" [content] no markdown heading ('# ...') in output")
record("--format markdown", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_BASIC, "--format", "html", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
html_file = _find_file_by_ext(tmpdir, ".html")
ok = html_file is not None and assert_content(
"--format html", html_file,
must_contain=["<html"],
case_insensitive=True,
)
if not ok and html_file is not None:
ok = assert_content(
"--format html <p", html_file,
must_contain=["<p"],
case_insensitive=True,
)
record("--format html", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_BASIC, "--format", "text", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
txt_file = _find_file_by_ext(tmpdir, ".txt")
ok = txt_file is not None
if ok:
with open(txt_file, "r", encoding="utf-8", errors="replace") as fh:
ok = bool(fh.read().strip())
if not ok:
print(" [content] text output is empty")
record("--format text", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_BASIC, "--format", "json", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
json_file = _find_file_by_ext(tmpdir, ".json")
ok = json_file is not None
if ok:
try:
with open(json_file, "r", encoding="utf-8") as fh:
json.load(fh)
except json.JSONDecodeError as exc:
print(f" [content] invalid JSON: {exc}")
ok = False
record("--format json", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_BASIC, "--format", "pdf", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
pdf_out = _find_file_by_ext(tmpdir, ".pdf")
ok = pdf_out is not None
if ok:
with open(pdf_out, "rb") as fh:
header = fh.read(5)
ok = header == b"%PDF-"
if not ok:
print(f" [content] bad PDF header: {header!r}")
record("--format pdf", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_BASIC, "--format", "tagged-pdf", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
pdf_out = _find_file_by_ext(tmpdir, ".pdf")
ok = pdf_out is not None
if ok:
with open(pdf_out, "rb") as fh:
header = fh.read(5)
ok = header == b"%PDF-"
if not ok:
print(f" [content] bad tagged-PDF header: {header!r}")
record("--format tagged-pdf", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_TABLES, "--format", "markdown-with-html", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None and assert_content(
"--format markdown-with-html", md_file,
must_contain=["<table"],
case_insensitive=True,
)
record("--format markdown-with-html", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_IMAGES, "--format", "markdown-with-images", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None
if ok:
with open(md_file, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
ok = "![" in content or "data:image/" in content
if not ok:
print(" [content] missing '![' or 'data:image/' in output")
record("--format markdown-with-images", ok)
# Multi-format
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([PDF_BASIC, "--format", "markdown,json,html", "--output-dir", tmpdir])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
json_file = _find_file_by_ext(tmpdir, ".json")
html_file = _find_file_by_ext(tmpdir, ".html")
ok = all([md_file, json_file, html_file])
if ok:
for path in [md_file, json_file, html_file]:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
if not fh.read().strip():
print(f" [content] empty output file: {path}")
ok = False
break
record("--format markdown,json,html (multi)", ok)
# ------------------------------------------------------------------
# --output-dir
# ------------------------------------------------------------------
print("\n--- --output-dir ---")
with tempfile.TemporaryDirectory() as tmpdir:
result = smoke("--output-dir", [PDF_BASIC, "--output-dir", tmpdir], tmpdir)
record("--output-dir", result)
# ------------------------------------------------------------------
# --quiet
# ------------------------------------------------------------------
print("\n--- --quiet ---")
with tempfile.TemporaryDirectory() as tmpdir:
result = assert_stderr_empty(
"--quiet",
[PDF_BASIC, "--quiet", "--output-dir", tmpdir],
)
record("--quiet", result)
# ------------------------------------------------------------------
# --to-stdout
# ------------------------------------------------------------------
print("\n--- --to-stdout ---")
result = assert_stdout_nonempty(
"--to-stdout",
[PDF_BASIC, "--to-stdout", "--format", "text"],
)
record("--to-stdout", result)
# ------------------------------------------------------------------
# --password
# ------------------------------------------------------------------
print("\n--- --password ---")
if not os.path.exists(PDF_PASSWORD):
record("--password [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as tmpdir:
result = smoke(
"--password",
[PDF_PASSWORD, "--password", PASSWORD, "--output-dir", tmpdir],
tmpdir,
)
record("--password", result)
# ------------------------------------------------------------------
# --keep-line-breaks
# ------------------------------------------------------------------
print("\n--- --keep-line-breaks ---")
if not os.path.exists(PDF_MULTIPAGE):
record("--keep-line-breaks [SKIPPED: fixture missing]", None)
else:
result = run_comparison(
"--keep-line-breaks",
cmd_baseline=[PDF_MULTIPAGE, "--format", "text"],
cmd_variant=[PDF_MULTIPAGE, "--format", "text", "--keep-line-breaks"],
expect="differ",
)
record("--keep-line-breaks", result)
# ------------------------------------------------------------------
# --replace-invalid-chars
# ------------------------------------------------------------------
print("\n--- --replace-invalid-chars ---")
if not os.path.exists(PDF_INVALID_CHARS):
record("--replace-invalid-chars [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as tmpdir:
# Collision-free sentinel: a bare "_" occurs in ordinary text, so the
# old check passed even if the option were ignored. This token cannot
# appear unless the replacement actually fired on the fixture's
# unmappable glyphs.
result = run_cli([
PDF_INVALID_CHARS,
"--replace-invalid-chars", "ODLINVALIDCHAR",
"--format", "text",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
txt_file = _find_file_by_ext(tmpdir, ".txt")
ok = txt_file is not None and assert_content(
"--replace-invalid-chars", txt_file, must_contain=["ODLINVALIDCHAR"]
)
record("--replace-invalid-chars ODLINVALIDCHAR", ok)
# ------------------------------------------------------------------
# --pages
# ------------------------------------------------------------------
print("\n--- --pages ---")
if not os.path.exists(PDF_MULTIPAGE):
record("--pages 1 [SKIPPED: fixture missing]", None)
record("--pages 1-2 [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as full_dir, \
tempfile.TemporaryDirectory() as p1_dir:
run_cli([PDF_MULTIPAGE, "--format", "text", "--output-dir", full_dir])
run_cli([PDF_MULTIPAGE, "--format", "text", "--pages", "1", "--output-dir", p1_dir])
full_file = _find_file_by_ext(full_dir, ".txt")
p1_file = _find_file_by_ext(p1_dir, ".txt")
if full_file and p1_file:
full_size = os.path.getsize(full_file)
ok = assert_file_size("--pages 1", p1_file, smaller_than=full_size)
else:
print(" [pages 1] missing output file(s)")
ok = False
record("--pages 1", ok)
with tempfile.TemporaryDirectory() as full_dir, \
tempfile.TemporaryDirectory() as p12_dir:
run_cli([PDF_MULTIPAGE, "--format", "text", "--output-dir", full_dir])
run_cli([PDF_MULTIPAGE, "--format", "text", "--pages", "1-2", "--output-dir", p12_dir])
full_file = _find_file_by_ext(full_dir, ".txt")
p12_file = _find_file_by_ext(p12_dir, ".txt")
if full_file and p12_file:
full_size = os.path.getsize(full_file)
ok = assert_file_size("--pages 1-2", p12_file, smaller_than=full_size)
else:
print(" [pages 1-2] missing output file(s)")
ok = False
record("--pages 1-2", ok)
# ------------------------------------------------------------------
# --include-header-footer
# ------------------------------------------------------------------
print("\n--- --include-header-footer ---")
if not os.path.exists(PDF_STRUCTURED):
record("--include-header-footer [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as base_dir, \
tempfile.TemporaryDirectory() as hf_dir:
run_cli([PDF_STRUCTURED, "--format", "text", "--output-dir", base_dir])
run_cli([PDF_STRUCTURED, "--format", "text", "--include-header-footer",
"--output-dir", hf_dir])
base_file = _find_file_by_ext(base_dir, ".txt")
hf_file = _find_file_by_ext(hf_dir, ".txt")
if base_file and hf_file:
base_size = os.path.getsize(base_file)
ok = assert_file_size("--include-header-footer", hf_file, larger_than=base_size)
else:
print(" [include-header-footer] missing output file(s)")
ok = False
record("--include-header-footer", ok)
# ------------------------------------------------------------------
# --detect-strikethrough
# ------------------------------------------------------------------
print("\n--- --detect-strikethrough ---")
if STRIKETHROUGH_KNOWN_ISSUE:
record("--detect-strikethrough [SKIPPED: known issue, see tasks#348]", None)
elif not os.path.exists(PDF_STRIKETHROUGH):
record("--detect-strikethrough [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_STRIKETHROUGH,
"--detect-strikethrough",
"--format", "markdown",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None and assert_content(
"--detect-strikethrough", md_file, must_contain=["~~"]
)
record("--detect-strikethrough", ok)
# ------------------------------------------------------------------
# --sanitize
# ------------------------------------------------------------------
print("\n--- --sanitize ---")
if not os.path.exists(PDF_SANITIZE):
record("--sanitize [SKIPPED: fixture missing]", None)
else:
result = run_comparison(
"--sanitize",
cmd_baseline=[PDF_SANITIZE, "--format", "text"],
cmd_variant=[PDF_SANITIZE, "--format", "text", "--sanitize"],
expect="differ",
)
record("--sanitize", result)
# ------------------------------------------------------------------
# --content-safety-off
# ------------------------------------------------------------------
print("\n--- --content-safety-off ---")
if not os.path.exists(PDF_SAFETY):
record("--content-safety-off all [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as tmpdir:
result = smoke(
"--content-safety-off all",
[PDF_SAFETY, "--content-safety-off", "all", "--format", "text",
"--output-dir", tmpdir],
tmpdir,
)
record("--content-safety-off all", result)
# ------------------------------------------------------------------
# --use-struct-tree
# ------------------------------------------------------------------
print("\n--- --use-struct-tree ---")
if not os.path.exists(PDF_STRUCTURED):
record("--use-struct-tree [SKIPPED: fixture missing]", None)
else:
result = run_comparison(
"--use-struct-tree",
cmd_baseline=[PDF_STRUCTURED, "--format", "text"],
cmd_variant=[PDF_STRUCTURED, "--format", "text", "--use-struct-tree"],
expect="differ",
)
record("--use-struct-tree", result)
# ------------------------------------------------------------------
# --table-method
# ------------------------------------------------------------------
print("\n--- --table-method ---")
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_TABLES,
"--table-method", "cluster",
"--format", "markdown",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None and assert_content(
"--table-method cluster", md_file, must_contain=["|"]
)
record("--table-method cluster", ok)
# ------------------------------------------------------------------
# --reading-order
# ------------------------------------------------------------------
print("\n--- --reading-order ---")
if not os.path.exists(PDF_MULTIPAGE):
record("--reading-order off [SKIPPED: fixture missing]", None)
else:
result = run_comparison(
"--reading-order off",
cmd_baseline=[PDF_MULTIPAGE, "--format", "text"],
cmd_variant=[PDF_MULTIPAGE, "--format", "text", "--reading-order", "off"],
expect="differ",
)
record("--reading-order off", result)
# ------------------------------------------------------------------
# --threads
# ------------------------------------------------------------------
print("\n--- --threads ---")
with tempfile.TemporaryDirectory() as tmpdir:
result = smoke(
"--threads 4",
[PDF_BASIC, "--threads", "4", "--output-dir", tmpdir],
tmpdir,
)
record("--threads 4", result)
# ------------------------------------------------------------------
# --image-output
# ------------------------------------------------------------------
print("\n--- --image-output ---")
# --image-output off: no image files generated
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_IMAGES,
"--format", "markdown",
"--image-output", "off",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
img_files = _find_image_files(tmpdir)
ok = len(img_files) == 0
if not ok:
print(f" [image-output off] found {len(img_files)} image file(s), expected 0")
record("--image-output off", ok)
# --image-output external: image files created separately
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_IMAGES,
"--format", "markdown",
"--image-output", "external",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
img_files = _find_image_files(tmpdir)
ok = len(img_files) > 0
if not ok:
print(" [image-output external] no image files created")
record("--image-output external", ok)
# --image-output embedded: markdown contains data:image/
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_IMAGES,
"--format", "markdown",
"--image-output", "embedded",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None and assert_content(
"--image-output embedded", md_file, must_contain=["data:image/"]
)
record("--image-output embedded", ok)
# ------------------------------------------------------------------
# --image-format
# ------------------------------------------------------------------
print("\n--- --image-format ---")
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_IMAGES,
"--format", "markdown",
"--image-output", "external",
"--image-format", "jpeg",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
# Look for .jpg or .jpeg files and check JPEG header
jpeg_files = []
for root, _dirs, files in os.walk(tmpdir):
for name in files:
if name.lower().endswith((".jpg", ".jpeg")):
jpeg_files.append(os.path.join(root, name))
ok = len(jpeg_files) > 0
if ok:
with open(jpeg_files[0], "rb") as fh:
header = fh.read(2)
ok = header == b"\xff\xd8"
if not ok:
print(f" [image-format jpeg] bad JPEG header: {header!r}")
else:
print(" [image-format jpeg] no .jpg/.jpeg files found")
record("--image-format jpeg", ok)
# ------------------------------------------------------------------
# --image-dir
# ------------------------------------------------------------------
print("\n--- --image-dir ---")
with tempfile.TemporaryDirectory() as tmpdir:
img_dir = os.path.join(tmpdir, "my_images")
os.makedirs(img_dir)
result = run_cli([
PDF_IMAGES,
"--format", "markdown",
"--image-output", "external",
"--image-dir", img_dir,
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
img_files = _find_image_files(img_dir)
ok = len(img_files) > 0
if not ok:
print(f" [image-dir] no image files in {img_dir}")
record("--image-dir", ok)
# ------------------------------------------------------------------
# Page separators
# ------------------------------------------------------------------
print("\n--- page separators ---")
if not os.path.exists(PDF_MULTIPAGE):
record("--markdown-page-separator [SKIPPED: fixture missing]", None)
record("--text-page-separator [SKIPPED: fixture missing]", None)
record("--html-page-separator [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_MULTIPAGE,
"--format", "markdown",
# Distinctive sentinel: a bare "---" also occurs as a markdown
# thematic break / table rule / front-matter fence, so asserting
# on it would pass even if the separator option were ignored.
"--markdown-page-separator=ODLPAGEBREAK",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None and assert_content(
"--markdown-page-separator", md_file, must_contain=["ODLPAGEBREAK"]
)
record("--markdown-page-separator ODLPAGEBREAK", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_MULTIPAGE,
"--format", "text",
"--text-page-separator", "PAGEBREAK",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
txt_file = _find_file_by_ext(tmpdir, ".txt")
ok = txt_file is not None and assert_content(
"--text-page-separator PAGEBREAK", txt_file, must_contain=["PAGEBREAK"]
)
record("--text-page-separator PAGEBREAK", ok)
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_MULTIPAGE,
"--format", "html",
# Distinctive marker class: a bare "<hr" can occur in normal HTML
# output, so a custom attribute proves the separator value is honored.
"--html-page-separator", '<hr class="odl-pagebreak"/>',
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
html_file = _find_file_by_ext(tmpdir, ".html")
ok = html_file is not None and assert_content(
"--html-page-separator", html_file, must_contain=["odl-pagebreak"]
)
record("--html-page-separator odl-pagebreak", ok)
# ------------------------------------------------------------------
# Combo tests
# ------------------------------------------------------------------
print("\n--- Combo tests ---")
# --image-output embedded + --image-format jpeg
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_IMAGES,
"--format", "markdown",
"--image-output", "embedded",
"--image-format", "jpeg",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None and assert_content(
"--image-output embedded + --image-format jpeg",
md_file,
must_contain=["data:image/jpeg"],
)
record("--image-output embedded + --image-format jpeg", ok)
# --keep-line-breaks + --detect-strikethrough + --sanitize
if not os.path.exists(PDF_SANITIZE):
record("--keep-line-breaks + --detect-strikethrough + --sanitize [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as tmpdir:
result = run_cli([
PDF_SANITIZE,
"--keep-line-breaks",
"--detect-strikethrough",
"--sanitize",
"--format", "markdown",
"--output-dir", tmpdir,
])
ok = result.returncode == 0
if ok:
md_file = _find_file_by_ext(tmpdir, ".md")
ok = md_file is not None
if ok:
ok = os.path.getsize(md_file) > 0
if not ok:
print(" [combo] output file is empty")
record("--keep-line-breaks + --detect-strikethrough + --sanitize", ok)
# --pages 1,2 + --format markdown,json
if not os.path.exists(PDF_MULTIPAGE):
record("--pages 1,2 + --format markdown,json [SKIPPED: fixture missing]", None)
else:
with tempfile.TemporaryDirectory() as full_dir, \
tempfile.TemporaryDirectory() as p12_dir:
run_cli([PDF_MULTIPAGE, "--format", "markdown,json", "--output-dir", full_dir])
run_cli([
PDF_MULTIPAGE,
"--pages", "1,2",
"--format", "markdown,json",
"--output-dir", p12_dir,
])
full_md = _find_file_by_ext(full_dir, ".md")
full_json = _find_file_by_ext(full_dir, ".json")
p12_md = _find_file_by_ext(p12_dir, ".md")
p12_json = _find_file_by_ext(p12_dir, ".json")
ok = all([full_md, full_json, p12_md, p12_json])
if ok:
full_md_size = os.path.getsize(full_md)
full_json_size = os.path.getsize(full_json)
ok = (
assert_file_size("--pages 1,2 md", p12_md, smaller_than=full_md_size)
and
assert_file_size("--pages 1,2 json", p12_json, smaller_than=full_json_size)
)
else:
print(" [pages 1,2 + multi-format] missing output file(s)")
record("--pages 1,2 + --format markdown,json", ok)
# --quiet + --to-stdout: stdout still contains output
result = assert_stdout_nonempty(
"--quiet + --to-stdout",
[PDF_BASIC, "--quiet", "--to-stdout", "--format", "text"],
)
record("--quiet + --to-stdout", result)
# ------------------------------------------------------------------
# Encoding safety: --help under cp949 codepage
# ------------------------------------------------------------------
print("\n--- --help cp949 encoding safety ---")
record("--help cp949 safe (main)", _verify_help_cp949_safe(
[sys.executable, "-m", "opendataloader_pdf"], required=True
))
record("--help cp949 safe (hybrid)", _verify_hybrid_help_cp949_safe())
# ------------------------------------------------------------------
# Hybrid backend failure contracts
# ------------------------------------------------------------------
print("\n--- hybrid backend failure contracts ---")
record(
"hybrid fail-fast on unreachable backend",
_verify_hybrid_fail_fast_on_unreachable_backend(),
)
# ------------------------------------------------------------------
# Input/output contract verifications (independent of CLI options)
# ------------------------------------------------------------------
verify_input_validation()
verify_folder_summary()
verify_markdown_with_html()
verify_sanitization_rules()
# ------------------------------------------------------------------
# Summary
# ------------------------------------------------------------------
passed = sum(1 for _, v in RESULTS if v is True)
failed = sum(1 for _, v in RESULTS if v is False)
skipped = sum(1 for _, v in RESULTS if v is None)
print()
print("=" * 60)
print(f"Results: {passed} passed, {failed} failed, {skipped} skipped")
print("=" * 60)
# Determine report filename
is_ci = os.environ.get("CI", "").lower() in ("true", "1", "yes")
report_name = "verification-report-ci.txt" if is_ci else "verification-report-local.txt"
report_path = os.path.join(REPO_ROOT, "verification", report_name)
with open(report_path, "w", encoding="utf-8") as rpt:
rpt.write("opendataloader-pdf CLI Verification Report\n")
rpt.write("=" * 60 + "\n")
for label, status in RESULTS:
if status is None:
marker = "SKIP"
elif status:
marker = "PASS"
else:
marker = "FAIL"
rpt.write(f" {marker} | {label}\n")
rpt.write("\n")
rpt.write(f"Results: {passed} passed, {failed} failed, {skipped} skipped\n")
print(f"Report written to: {report_path}")
# Append to GitHub Step Summary if available
step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
if step_summary:
with open(step_summary, "a", encoding="utf-8") as gs:
gs.write("## opendataloader-pdf CLI Verification\n\n")
gs.write("| Status | Test |\n")
gs.write("|--------|------|\n")
for label, status in RESULTS:
if status is None:
marker = "SKIP"
elif status:
marker = "PASS"
else:
marker = "FAIL"
gs.write(f"| {marker} | {label} |\n")
gs.write(f"\n**Results: {passed} passed, {failed} failed, {skipped} skipped**\n")
sys.exit(1 if failed > 0 else 0)
if __name__ == "__main__":
main()