outloud 0.2.0__py3-none-any.whl

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.
outloud/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """outloud: a fast PDF accessibility checker for the terminal.
2
+
3
+ from outloud import check
4
+ result = check("report.pdf")
5
+ print(result.verdict, result.errors, result.warnings)
6
+ for row in result.criteria["wcag22"]:
7
+ print(row["id"], row["name"], row["status"])
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import Optional
14
+
15
+ from .findings import Result
16
+ from .model import Document
17
+ from .registry import features, load_catalogue, run_rules
18
+
19
+ __version__ = "0.2.0"
20
+
21
+
22
+ def check(path: str, source: Optional[str] = None, only=None, skip=None, layers=None) -> Result:
23
+ """Check one PDF and return a Result; never raises for a bad file."""
24
+ from .criteria import evaluate # noqa: PLC0415
25
+
26
+ t0 = time.perf_counter()
27
+ result = Result(path=path)
28
+ doc = None
29
+ try:
30
+ doc = Document(path, source=source)
31
+ pages = doc.pages
32
+ result.stats["pages"] = len(pages)
33
+ result.stats["tagged"] = bool(doc.struct_root is not None)
34
+ feats = features(doc)
35
+ result.stats["features"] = {k: v for k, v in feats.items() if v}
36
+ run_rules(doc, result, only=set(only) if only else None, skip=set(skip) if skip else None,
37
+ layers=set(layers) if layers else None, feats=feats)
38
+ result.stats["elements"] = len(doc.elements)
39
+ result.stats["fonts"] = len(doc.fonts)
40
+ result.criteria = evaluate(result, load_catalogue())
41
+ except Exception as exc: # noqa: BLE001
42
+ result.error = f"{type(exc).__name__}: {exc}"[:300]
43
+ finally:
44
+ if doc is not None:
45
+ doc.close()
46
+ result.seconds = time.perf_counter() - t0
47
+ return result
outloud/cli.py ADDED
@@ -0,0 +1,125 @@
1
+ """The outloud command.
2
+
3
+ outloud report.pdf one file, findings on the terminal
4
+ outloud a.pdf b.pdf dir/ several; directories are searched for *.pdf
5
+ outloud out.pdf --source in.pdf also compare the output with its source
6
+ outloud *.pdf --json report.json --sarif report.sarif --html report.html
7
+ outloud --list-rules
8
+
9
+ Exit status: 0 when every file passes or needs review, 1 when any file
10
+ fails, 2 when any file could not be read. --fail-on changes the threshold.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+
18
+ import click
19
+
20
+ from . import __version__, check
21
+ from .findings import Result
22
+ from .report import batch_table, criteria_table, one_line, rules_table, terminal, to_html, to_json, to_sarif
23
+
24
+
25
+ def _expand(paths: tuple[str, ...]) -> list[str]:
26
+ out: list[str] = []
27
+ for p in paths:
28
+ if os.path.isdir(p):
29
+ for root, _dirs, files in os.walk(p):
30
+ for f in sorted(files):
31
+ if f.lower().endswith(".pdf"):
32
+ out.append(os.path.join(root, f))
33
+ else:
34
+ out.append(p)
35
+ return out
36
+
37
+
38
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
39
+ @click.argument("paths", nargs=-1, type=click.Path())
40
+ @click.option("--source", type=click.Path(exists=True), help="The original PDF the checked file was made from; enables the SEM-* comparisons.")
41
+ @click.option("--json", "json_path", type=click.Path(), help="Write a JSON report to this path ('-' for stdout).")
42
+ @click.option("--sarif", "sarif_path", type=click.Path(), help="Write a SARIF 2.1.0 report to this path.")
43
+ @click.option("--html", "html_path", type=click.Path(), help="Write an HTML report to this path.")
44
+ @click.option("--only", multiple=True, help="Run only these rule ids (repeatable, or comma-separated).")
45
+ @click.option("--skip", multiple=True, help="Skip these rule ids (repeatable, or comma-separated).")
46
+ @click.option("--layer", type=click.Choice(["conformance", "semantic"]), multiple=True, help="Run only rules of this layer.")
47
+ @click.option("--fail-on", type=click.Choice(["error", "warning", "never"]), default="error", show_default=True,
48
+ help="Which severity makes the exit status non-zero.")
49
+ @click.option("-q", "--quiet", is_flag=True, help="One line per file, no findings.")
50
+ @click.option("--no-info", is_flag=True, help="Hide info-level findings on the terminal.")
51
+ @click.option("--criteria", is_flag=True,
52
+ help="Print the criteria view: every Matterhorn checkpoint and WCAG 2.2 A/AA success criterion with its status (pass, fail, warning, not applicable, needs a person) and the rules behind it.")
53
+ @click.option("--view", is_flag=True, help="Open the app in the browser: drop or choose PDFs, or pass them here; pages with findings outlined, criteria, structure tree and a screen-reader preview.")
54
+ @click.option("--port", type=int, default=0, help="Port for --view (default: any free port).")
55
+ @click.option("--no-browser", is_flag=True, help="With --view: serve without opening a browser window; print the URL instead.")
56
+ @click.option("--list-rules", is_flag=True, help="Print the rule catalogue and exit.")
57
+ @click.version_option(__version__, prog_name="outloud")
58
+ def main(paths, source, json_path, sarif_path, html_path, only, skip, layer, fail_on, quiet, no_info, criteria, view, port, no_browser, list_rules):
59
+ """Check PDF files for accessibility: PDF/UA-1 conformance plus semantic checks a validator cannot make."""
60
+ if list_rules:
61
+ click.echo(rules_table())
62
+ return
63
+ files = _expand(paths)
64
+ if not files and not view:
65
+ raise click.UsageError("give at least one PDF file or directory, or --list-rules, or --view to open the app")
66
+ only_ids = {x.strip() for item in only for x in item.split(",") if x.strip()} or None
67
+ skip_ids = {x.strip() for item in skip for x in item.split(",") if x.strip()} or None
68
+ results: list[Result] = []
69
+ for path in files:
70
+ if not os.path.exists(path):
71
+ r = Result(path=path, error="no such file")
72
+ else:
73
+ r = check(path, source=source, only=only_ids, skip=skip_ids, layers=set(layer) or None)
74
+ results.append(r)
75
+ if json_path == "-":
76
+ continue
77
+ if quiet or len(files) > 1:
78
+ click.echo(one_line(r))
79
+ else:
80
+ click.echo(terminal(r, verbose=True, show_info=not no_info))
81
+ if criteria and not r.error:
82
+ if len(files) > 1:
83
+ click.echo(f" {os.path.basename(r.path)}")
84
+ click.echo(criteria_table(r, "both"))
85
+ click.echo("")
86
+ if len(files) > 1 and json_path != "-" and not quiet:
87
+ click.echo("")
88
+ click.echo(batch_table(results))
89
+ if json_path:
90
+ text = to_json(results)
91
+ if json_path == "-":
92
+ click.echo(text)
93
+ else:
94
+ with open(json_path, "w", encoding="utf-8") as fh:
95
+ fh.write(text)
96
+ if sarif_path:
97
+ with open(sarif_path, "w", encoding="utf-8") as fh:
98
+ fh.write(to_sarif(results))
99
+ if html_path:
100
+ with open(html_path, "w", encoding="utf-8") as fh:
101
+ fh.write(to_html(results))
102
+ if view:
103
+ from .model import Document # noqa: PLC0415
104
+ from .viewer import serve # noqa: PLC0415
105
+
106
+ ok = [r for r in results if not r.error]
107
+ docs = [Document(r.path, source=source) for r in ok]
108
+ click.echo("viewer running; press Ctrl+C to stop", err=True)
109
+ try:
110
+ serve(ok, docs, port=port, open_browser=not no_browser)
111
+ finally:
112
+ for d in docs:
113
+ d.close()
114
+ code = 0
115
+ if any(r.error for r in results):
116
+ code = 2
117
+ elif fail_on == "error" and any(r.errors for r in results):
118
+ code = 1
119
+ elif fail_on == "warning" and any(r.errors or r.warnings for r in results):
120
+ code = 1
121
+ sys.exit(code)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()