cappv1 1.7.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.
Files changed (86) hide show
  1. cappv1/.env.example +33 -0
  2. cappv1/CHANGELOG.md +140 -0
  3. cappv1/__init__.py +0 -0
  4. cappv1/__main__.py +5 -0
  5. cappv1/audit.py +377 -0
  6. cappv1/audit_color.py +87 -0
  7. cappv1/config.py +101 -0
  8. cappv1/doctor.py +216 -0
  9. cappv1/docx_check.py +234 -0
  10. cappv1/docx_pipeline.py +300 -0
  11. cappv1/docx_reader.py +358 -0
  12. cappv1/docx_structure.py +210 -0
  13. cappv1/events.py +31 -0
  14. cappv1/home.py +23 -0
  15. cappv1/image_filter.py +26 -0
  16. cappv1/llm_client.py +315 -0
  17. cappv1/llm_prompts.py +109 -0
  18. cappv1/llm_providers.py +87 -0
  19. cappv1/logsetup.py +63 -0
  20. cappv1/main.py +77 -0
  21. cappv1/models.py +147 -0
  22. cappv1/outputs.py +79 -0
  23. cappv1/pipeline.py +544 -0
  24. cappv1/pptx_reader.py +458 -0
  25. cappv1/services/__init__.py +7 -0
  26. cappv1/services/audit.py +27 -0
  27. cappv1/services/envfile.py +36 -0
  28. cappv1/services/errors.py +9 -0
  29. cappv1/services/files.py +191 -0
  30. cappv1/services/health.py +80 -0
  31. cappv1/services/models.py +86 -0
  32. cappv1/services/runs.py +181 -0
  33. cappv1/services/settings.py +143 -0
  34. cappv1/services/storage.py +65 -0
  35. cappv1/services/workspace.py +39 -0
  36. cappv1/state.py +73 -0
  37. cappv1/tui/__init__.py +1 -0
  38. cappv1/tui/__main__.py +5 -0
  39. cappv1/tui/anim.py +64 -0
  40. cappv1/tui/commands.py +382 -0
  41. cappv1/tui/common.py +31 -0
  42. cappv1/tui/menu.py +73 -0
  43. cappv1/tui/runview.py +187 -0
  44. cappv1/tui/session.py +277 -0
  45. cappv1/tui/setup_flow.py +211 -0
  46. cappv1/tui/theme.py +19 -0
  47. cappv1/version.py +4 -0
  48. cappv1/web/__init__.py +5 -0
  49. cappv1/web/api.py +263 -0
  50. cappv1/web/changelog.py +79 -0
  51. cappv1/web/pages/404.html +4 -0
  52. cappv1/web/pages/about.html +27 -0
  53. cappv1/web/pages/appearance.html +31 -0
  54. cappv1/web/pages/changelog.html +4 -0
  55. cappv1/web/pages/data.html +19 -0
  56. cappv1/web/pages/faq.html +55 -0
  57. cappv1/web/pages/files.html +57 -0
  58. cappv1/web/pages/health.html +15 -0
  59. cappv1/web/pages/how-it-works.html +44 -0
  60. cappv1/web/pages/index.html +92 -0
  61. cappv1/web/pages/privacy.html +78 -0
  62. cappv1/web/pages/services.html +22 -0
  63. cappv1/web/server.py +341 -0
  64. cappv1/web/static/app.css +287 -0
  65. cappv1/web/static/app.js +73 -0
  66. cappv1/web/static/appearance.js +23 -0
  67. cappv1/web/static/dashboard.js +77 -0
  68. cappv1/web/static/data.js +34 -0
  69. cappv1/web/static/favicon.svg +6 -0
  70. cappv1/web/static/files.js +506 -0
  71. cappv1/web/static/fonts/NOTICE.txt +3 -0
  72. cappv1/web/static/fonts/ah-400.woff2 +0 -0
  73. cappv1/web/static/fonts/ah-700.woff2 +0 -0
  74. cappv1/web/static/health.js +31 -0
  75. cappv1/web/static/icon-192.png +0 -0
  76. cappv1/web/static/icon-512.png +0 -0
  77. cappv1/web/static/manifest.webmanifest +15 -0
  78. cappv1/web/static/services.js +122 -0
  79. cappv1/web/static/shell.js +12 -0
  80. cappv1/web/static/theme.js +8 -0
  81. cappv1/web/templates/app.html +75 -0
  82. cappv1-1.7.0.dist-info/METADATA +138 -0
  83. cappv1-1.7.0.dist-info/RECORD +86 -0
  84. cappv1-1.7.0.dist-info/WHEEL +4 -0
  85. cappv1-1.7.0.dist-info/entry_points.txt +2 -0
  86. cappv1-1.7.0.dist-info/licenses/LICENSE +21 -0
cappv1/.env.example ADDED
@@ -0,0 +1,33 @@
1
+ # Which AI service to use: openrouter, openai, anthropic, or ollama.
2
+ # ollama runs the model on this computer: free, offline, and nothing leaves it
3
+ # (see the README, "Private mode"). It needs no key.
4
+ # Leave PROVIDER out and the first key you fill in below is used.
5
+ PROVIDER=openrouter
6
+
7
+ # Fill in the key for the provider you chose (the others can stay blank).
8
+ OPENROUTER_API_KEY=your-key-here
9
+ OPENAI_API_KEY=
10
+ ANTHROPIC_API_KEY=
11
+
12
+ # MODEL / FALLBACK_MODEL must be models from the provider you chose.
13
+ # Leave MODEL blank to use that provider's default:
14
+ # openrouter: minimax/minimax-m3:free (fallback google/gemma-3-12b-it)
15
+ # openai: gpt-4o-mini
16
+ # anthropic: claude-haiku-5-5
17
+ # ollama: gemma3:4b (must be a vision model you've downloaded with: ollama pull <name>)
18
+ # FALLBACK_MODEL is tried when MODEL is unavailable, rate-limited, or out of credits.
19
+ # Avoid "reasoning" models here - they burn completion tokens on hidden thinking,
20
+ # which can starve short caption outputs.
21
+ MODEL=
22
+ FALLBACK_MODEL=
23
+ # Leave blank for the default: 4 at a time (1 for ollama).
24
+ CONCURRENCY=
25
+ MIN_IMAGE_SIZE_PX=40
26
+ # Alt text the author already wrote that is shorter than this is treated as unfinished
27
+ # and improved; longer text is kept unless the AI judges it inadequate. Blank = 30.
28
+ MIN_EXISTING_ALT_CHARS=
29
+ MAX_ALT_TEXT_CHARS=125
30
+
31
+ # Ollama only. Defaults shown; you normally don't need to touch these.
32
+ # OLLAMA_BASE_URL=http://localhost:11434
33
+ # REQUEST_TIMEOUT=300
cappv1/CHANGELOG.md ADDED
@@ -0,0 +1,140 @@
1
+ # Changelog
2
+
3
+ All notable changes to cappv1. Versions follow [Semantic Versioning](https://semver.org): MAJOR.MINOR.PATCH.
4
+
5
+ ## 1.7.0 - 2026-10-30
6
+
7
+ ### New
8
+
9
+ - Changelog page under Learn: a card per release with its date, grouped into new, improved and fixed. It is read from this file, so it is always current.
10
+ - Appearance page under Learn: choose the theme and an accent color (blue, teal, green, purple, rose or amber) for buttons and links, each checked for contrast in light and dark. Remembered in this browser. They are no longer in the sidebar.
11
+ - Install with `pipx install cappv1` (or `pipx install git+https://github.com/srsuiux/cappv1.git`). No cloning, symlink or virtual environment.
12
+ - cappv1 is published on PyPI, so `pipx install cappv1` works. Releases are built and uploaded by GitHub Actions when a version tag is pushed, using PyPI Trusted Publishing (no stored token).
13
+ - Install cappv1 from the browser as an app with its own window. It still needs to be running, and the address it prints, to connect.
14
+ - Each run keeps its finished copies in `output/<date>_<time>/` instead of replacing the last ones. Download or delete one copy or a whole run from the Files page, or in the terminal with `/history` and `/remove`; both see the change. Copies left loose in `output/` by older versions show as the oldest run.
15
+
16
+ ### Changed
17
+
18
+ - **You will notice:** `cappv1 --batch` now runs in the foreground and prints the log, instead of starting a background job and returning. A script that relied on it returning at once should run `cappv1 --batch &` itself.
19
+ - **You will notice:** when cappv1 is installed with pipx, your settings (`.env`), `input`, `output`, saved progress and logs live in `~/cappv1` (or the folder in `CAPPV1_HOME`). A cloned copy still uses its own project folder, so nothing moves for existing users; if you switch from a clone to pipx, copy `.env` and the folders you want into `~/cappv1`.
20
+
21
+ ### Improved
22
+
23
+ - The Python package is now `cappv1` (it was `src`), and `pyproject.toml` is the only place dependencies are listed. `python -m cappv1` also works.
24
+ - Checks on every push and pull request: ruff (lint and format), mypy, tests on Linux and macOS with Python 3.11 to 3.13 and coverage, a wheel build that is installed and run, and a link check for the docs. Dependabot keeps dependencies and Actions up to date, and `pre-commit` runs the same lint locally.
25
+ - Type checking: mypy now covers every module except a short list still being fixed (tracked in issue #1). Five modules were fixed in this release.
26
+ - A test runs the sample deck and checks that only alt text changed in the finished copy.
27
+ - The docs are split into focused pages under `docs/`, with an index.
28
+ - Every page now uses the full window width, in the same card layout as Files, with a shared page header.
29
+ - The window is fixed to the screen and only the content scrolls; the sidebar no longer folds.
30
+ - Files page: three panels (Current files, Past files, Finished copies), with sizes and dates on each card. Long processing notes moved into a ? on the card that shows them on hover, focus or tap.
31
+
32
+ ### Fixed
33
+
34
+ - PowerPoint: finished decks open without the repair prompt and preview in Blackboard. The output is now the original file with only the slides that got alt text replaced, instead of a python-pptx re-save, which dropped embedded-object relationships with `Target="NULL"` and left slides pointing at nothing.
35
+
36
+ ## 1.6.0 - 2026-10-29
37
+
38
+ ### New
39
+
40
+ - Word: Blackboard Ally structure fixes. Heading levels are renumbered to start at Heading 1 with no gaps, using copies of the existing heading styles so nothing looks different; captions become Heading 1 in a document with no headings; empty headings stop being headings; every table gets a header row, including one-row boxes; the Title property is filled when empty or wrong; text that fails the 4.5:1 contrast check is darkened to 7:1 and noted (the one visible change). `python -m src.docx_check` checks a file against these rules.
41
+ - Word: a table's first row is now marked as its header (the Header Row box and Repeat as header row), which Blackboard Ally needs: a test file scored 100% with both and 68% with the box alone. Only plain tables: no table style, two or more rows and columns, text in every first-row cell, nothing merged. One visible effect, which the owner accepted: a table that runs onto a second page repeats its header row there. Applied even in documents where table descriptions are skipped.
42
+ - Word: embedded objects (for example Visio drawings) now get alt text, so an accessibility checker no longer sees them as undescribed pictures. The Privacy page now lists the text read from an embedded object.
43
+ - Files page: every file has a checkbox, with Select all and a count. "Improve accessibility" runs only the ticked files (`POST /runs` takes `files`). New files are ticked for you; previous files can be ticked too. "Remove all" empties the Current files list after an inline confirmation.
44
+
45
+ ### Improved
46
+
47
+ - Sidebar: an icon beside every link, and a button that folds it to an icon rail (remembered in this browser). The theme choice is now three icon buttons (System, Light, Dark) with tooltips and screen-reader names.
48
+ - A damaged item no longer stops a file. A slide or document item that cannot be read (for example an embedded object whose part is missing, which used to end the file with "no relationship with key 'rId4'") is skipped, noted in the file's saved progress and the log, and the rest finishes. The Files page lists what was skipped under each file and marks the slide, and the terminal run view prints it. Applies to decks and Word documents.
49
+ - A run started from the browser now prints its log in the terminal that started cappv1 (`--web`, or `/web` in the terminal session).
50
+
51
+ ### Fixed
52
+
53
+ - Word: finished copies open in Word and preview in Blackboard. The output is now the original file with only `word/document.xml` replaced, instead of a python-docx re-save. Documents saved in Word compatibility mode 11 or 12 (what LibreOffice writes) get no table alt text, because Word refuses to open them with it; picture alt text is unaffected, and one note says to use Save As in Word first.
54
+ - Fixes a stray "null" shown on the finished-run card.
55
+
56
+ ## 1.5.0 - 2026-10-29
57
+
58
+ ### New
59
+
60
+ - Word documents (`.docx`): pictures and tables get alt text (`wp:docPr` description and `w:tblDescription`), through the terminal, the batch command and the web UI. Charts, SmartArt, embedded objects and headers/footers are not handled yet, and `/audit` stays decks-only.
61
+
62
+ ### Improved
63
+
64
+ - The Files page shows a deck's slide tiles or a document's item chips, lists current files apart from previous (already finished) ones, and always shows the "replace alt text" option, off by default.
65
+ - Wording now describes cappv1 as improving accessibility (by writing alt text) for PowerPoint and Word.
66
+
67
+ ### Changed
68
+
69
+ - API: the upload error code `not_pptx` is now `unsupported_file`; the audit of a Word file returns `audit_unsupported`. Run events carry `kind`.
70
+
71
+ ## 1.4.3 - 2026-10-27
72
+
73
+ - On the Files page, a finished run shows every slide tile instead of clipping long decks.
74
+
75
+ ## 1.4.2 - 2026-10-27
76
+
77
+ - The contrast audit now also checks text in table cells and in grouped shapes.
78
+
79
+ ## 1.4.1 - 2026-10-27
80
+
81
+ - Fixes a syntax error that stopped cappv1 from installing on Python 3.11 (1.4.0 only works on 3.12 and newer, so use 1.4.1).
82
+
83
+ ## 1.4.0 - 2026-10-27
84
+
85
+ - The Files page now puts a run front and center: a card at the top shows each slide lighting up and ticking off, what cappv1 is reading, and, when done, a clear "Your finished document is ready" card with Download, the saved location, and a note that the original is unchanged. The finished file is marked in the list too. Animations respect reduced-motion settings.
86
+ - The Files page has a Check accessibility button that shows the same report in the browser.
87
+ - New `/audit` command and `GET /api/v1/files/<name>/audit`: reports low-contrast text (WCAG AA, judged against the real backdrop including template shapes, patterns and gradients) and slides whose reading order doesn't follow the layout. Findings with the same cause are grouped across slides. It only reports; colors and layout are never changed.
88
+
89
+ ## 1.3.0 - 2026-10-27
90
+
91
+ - A table's first row is now flagged as its header when that changes nothing visually (no table style, two or more rows, every first-row cell filled, nothing merged). This fixes Blackboard Ally's "tables are missing headers". Tables where it would change the look are left alone and the reason is logged.
92
+ - `skills.md` now allows a short list of visually inert accessibility fixes beyond alt text. Colors, including low contrast, are never changed.
93
+
94
+ ## 1.2.0 - 2026-10-27
95
+
96
+ ### Added
97
+ - **Local web interface** (`cappv1 --web`, or `/web` inside cappv1; `/init` now ends by offering the browser or the terminal). It is a small site served on this computer only:
98
+ - A front page that introduces cappv1 to new visitors and, once you're set up, leads with an Overview (next step, file counts, service, health, data) and moves the introduction below it.
99
+ - Files (add decks, run, live feed of the alt text being written, stop, download), Services (choose service, model and key), Health, and Your data (what is kept and where, with an erase button).
100
+ - How it works, Privacy, FAQ and About pages. The Privacy page states exactly what is sent to an AI service and what is stored, and a test keeps it in step with the code.
101
+ - Light, dark and system themes with a switch in the side panel, applied before the page paints; a test checks the contrast of every color pair in both themes. Keyboard, screen-reader and small-screen friendly; a favicon.
102
+ - **Web server and API** (`src/web`, standard library only, documented in `docs/API.md`): bound to this computer, per-launch token, Host check, strict content policy, size-limited validated uploads, live progress over Server-Sent Events that resumes after a dropped connection.
103
+ - Each service says what it does with your slides and links to its privacy policy; saved API keys can be removed from Services.
104
+ - A new `alt_written` progress event carrying each piece of alt text as it is settled.
105
+ - `src/services/`, a service layer between the engine and the screens, with a pytest suite and GitHub Actions CI.
106
+ - `CAPPV1_HOME` to keep settings, decks and progress in a folder of your choice.
107
+ - Project files: `SECURITY.md`, `CODE_OF_CONDUCT.md`, issue and pull request templates, `.editorconfig`, `pyproject.toml`, and `docs/ARCHITECTURE.md`.
108
+
109
+ ### Fixed
110
+ - Pictures are matched by their exact bytes. Before, flat or near-identical images (for example two solid-color pictures) could share one caption.
111
+ - A picture whose "alt text" is only its file name (`image1.png`, filled in by some tools) is no longer treated as text the author wrote.
112
+ - The terminal toolbar no longer re-reads every deck on each keystroke.
113
+
114
+ ### Changed
115
+ - No more colored bars down one side of panels, menu items or messages; they use whole, subtle borders instead. The three steps and other boxes follow the same paper-like style.
116
+ - `cappv1` is now the only command in the shell. `cappv1 doctor`, `models`, `clean`, `init` and `start` are refused; use `/doctor`, `/models`, `/clean` and the others inside the session. `cappv1 --batch` remains for scripts, plus `--version` and `--help`.
117
+ - `/init` shows the current service and model first and lets you keep it, change only the model, or switch service, instead of asking everything again. `/models` goes straight to the service and model pickers. Both are listed in `/help` and in the `/` menu.
118
+
119
+ ## 1.1.0 - 2026-10-26
120
+
121
+ ### Added
122
+ - Interactive terminal session (`cappv1`, `cappv1 start`, `cappv1 init`): slash commands with completion, arrow-key menus, drag-and-drop of `.pptx` files, and a live run view whose animation follows what is happening (picture, chart, table, diagram, saving).
123
+ - Local, private captioning with Ollama (`PROVIDER=ollama`); nothing leaves the computer, and a remote Ollama address is refused unless explicitly allowed.
124
+ - `/models`: one flow to switch AI service and model, including free and paid OpenRouter vision models with prices (reaches DeepSeek, Google, OpenAI and more).
125
+ - Alt text that authors already wrote is built on, not replaced: short text is improved, adequate text is kept as written. `/run --force` replaces it after confirmation.
126
+ - Edited decks are recognised by content hash and reprocessed; unchanged items reuse saved captions. Unchanged files are reported as already captioned.
127
+
128
+ ### Fixed
129
+ - Resuming after a crash lost the captions of slides finished before the crash.
130
+ - One unreadable progress file stopped the whole run; it is now set aside.
131
+ - Output and progress files are written atomically, so a crash cannot leave a half-written file.
132
+ - An edited deck with the same filename was skipped as "already complete".
133
+
134
+ ### Changed
135
+ - `cappv1 start` opens the interactive session. The old background behavior is `cappv1 start --batch` (also used automatically when there is no terminal).
136
+
137
+ ## 1.0.0 - 2026-10-26 (tag `engine-cappv1-1.0.0`)
138
+
139
+ - Terminal engine: alt text for pictures, tables, charts, SmartArt and embedded objects; invisible shapes marked decorative; files processed in sequence and resumable.
140
+ - Providers: OpenRouter, OpenAI, Anthropic, and Ollama.
cappv1/__init__.py ADDED
File without changes
cappv1/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from cappv1.main import main
4
+
5
+ sys.exit(main())
cappv1/audit.py ADDED
@@ -0,0 +1,377 @@
1
+ """Read-only accessibility checks that report problems but never change a deck.
2
+
3
+ Two checks: text that is hard to read against its background, and slides whose
4
+ reading order (the order a screen reader follows, which is the order shapes
5
+ appear in the slide's XML) disagrees with how they are laid out. Colors and
6
+ layout belong to the author, so nothing here is ever written back."""
7
+
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ from pptx.oxml.ns import qn
12
+
13
+ from cappv1.audit_color import _Theme, contrast_ratio
14
+ from cappv1.pptx_reader import open_presentation
15
+
16
+ _A = "http://schemas.openxmlformats.org/drawingml/2006/main"
17
+ NORMAL_MIN, LARGE_MIN = 4.5, 3.0 # WCAG 2.x AA
18
+ _EMU_PER_PT = 12700
19
+
20
+
21
+ @dataclass
22
+ class Finding:
23
+ slide: int
24
+ kind: str # contrast | reading_order
25
+ message: str
26
+ detail: str = ""
27
+
28
+ def as_dict(self) -> dict:
29
+ return asdict(self)
30
+
31
+
32
+ @dataclass
33
+ class Report:
34
+ findings: list
35
+ checked_text: int # text runs whose colors could be resolved and measured
36
+ unmeasured_text: int # runs on gradients, pictures or unusual colors: not judged
37
+
38
+ def grouped(self) -> list:
39
+ """Findings with the same cause collapsed: one entry, many slides."""
40
+ groups: dict = {}
41
+ for f in self.findings:
42
+ key = (f.kind, f.detail if f.kind == "contrast" else f.message + f.detail)
43
+ g = groups.setdefault(key, {"kind": f.kind, "message": f.message, "detail": f.detail, "slides": []})
44
+ g["slides"].append(f.slide)
45
+ return list(groups.values())
46
+
47
+ def as_dict(self) -> dict:
48
+ return {
49
+ "groups": self.grouped(),
50
+ "problems": len(self.findings),
51
+ "checked_text": self.checked_text,
52
+ "unmeasured_text": self.unmeasured_text,
53
+ }
54
+
55
+
56
+ def _fill_colors(theme: _Theme, pr, phclr=None) -> list | None:
57
+ """Every color a fill can show: one for solid, the stops for a gradient, both
58
+ colors for a pattern. None when it can't be judged (a picture, or odd colors)."""
59
+ if pr is None:
60
+ return None
61
+ for tag, path in (("solidFill", "."), ("gradFill", ".//" + f"{{{_A}}}gs"), ("pattFill", None)):
62
+ el = pr.find(f"{{{_A}}}{tag}")
63
+ if el is None:
64
+ continue
65
+ if tag == "pattFill":
66
+ parts = [c[0] for c in (el.find(f"{{{_A}}}fgClr"), el.find(f"{{{_A}}}bgClr")) if c is not None and len(c)]
67
+ elif tag == "gradFill":
68
+ parts = [gs[0] for gs in el.iter(f"{{{_A}}}gs") if len(gs)]
69
+ else:
70
+ parts = [el[0]] if len(el) else []
71
+ colors = [theme.resolve(p, phclr) for p in parts]
72
+ return colors if colors and all(colors) else None
73
+ return None
74
+
75
+
76
+ def _has_nonsolid_fill(el) -> bool:
77
+ return el is not None and any(el.find(f"{{{_A}}}{t}") is not None for t in ("gradFill", "blipFill", "pattFill"))
78
+
79
+
80
+ def _background(theme: _Theme, slide) -> str | None:
81
+ """The slide's background color: its own, else its layout's, else the master's."""
82
+ for part in (slide, slide.slide_layout, slide.slide_layout.slide_master):
83
+ bg = part._element.find(f".//{qn('p:bg')}")
84
+ if bg is None:
85
+ continue
86
+ pr = bg.find(qn("p:bgPr"))
87
+ if pr is not None:
88
+ return _fill_colors(theme, pr)
89
+ ref = bg.find(qn("p:bgRef"))
90
+ if ref is not None and len(ref):
91
+ phclr = theme.resolve(ref[0])
92
+ idx = int(ref.get("idx", "0"))
93
+ if 1001 <= idx < 1001 + len(theme.fills) and phclr:
94
+ style = theme.fills[idx - 1001]
95
+ if style.tag.endswith("solidFill") and len(style):
96
+ return [theme.resolve(style[0], phclr)]
97
+ return None
98
+ return ["FFFFFF"]
99
+
100
+
101
+ def _shape_fill(theme: _Theme, el) -> tuple[bool, list | None]:
102
+ """(has_own_fill, colors). Own fills on a text shape replace the slide background."""
103
+ sp_pr = el.find(qn("p:spPr"))
104
+ if sp_pr is None or sp_pr.find(f"{{{_A}}}noFill") is not None:
105
+ return False, None
106
+ if not any(sp_pr.find(f"{{{_A}}}{t}") is not None for t in ("solidFill", "gradFill", "blipFill", "pattFill")):
107
+ ref = el.find(f"{qn('p:style')}/{{{_A}}}fillRef")
108
+ if ref is not None and ref.get("idx", "0") != "0":
109
+ return True, None # filled by the theme's shape style: not judged
110
+ return False, None
111
+ return True, _fill_colors(theme, sp_pr)
112
+
113
+
114
+ # ---- contrast --------------------------------------------------------------------
115
+ def _placeholder_chain(slide, sp):
116
+ """The shape's own element, then the layout's and master's matching placeholders."""
117
+ chain = [sp]
118
+ ph = sp.find(f"{qn('p:nvSpPr')}/{qn('p:nvPr')}/{qn('p:ph')}")
119
+ if ph is None:
120
+ return chain, None
121
+ for part in (slide.slide_layout, slide.slide_layout.slide_master):
122
+ for cand in part._element.iter(qn("p:sp")):
123
+ cph = cand.find(f"{qn('p:nvSpPr')}/{qn('p:nvPr')}/{qn('p:ph')}")
124
+ if cph is None:
125
+ continue
126
+ same_idx = ph.get("idx") is not None and cph.get("idx") == ph.get("idx")
127
+ same_type = ph.get("idx") is None and (cph.get("type") or "obj") == (ph.get("type") or "obj")
128
+ title = {"title", "ctrTitle"}
129
+ if same_idx or same_type or (ph.get("type") in title and cph.get("type") in title):
130
+ chain.append(cand)
131
+ break
132
+ return chain, ph.get("type") or "body"
133
+
134
+
135
+ def _run_color(theme: _Theme, run, para, slide, sp) -> str | None:
136
+ rpr = run.find(qn("a:rPr"))
137
+ if rpr is not None and _has_nonsolid_fill(rpr):
138
+ return None
139
+ color = theme.solid(rpr) if rpr is not None else None
140
+ if color:
141
+ return color
142
+ ppr = para.find(qn("a:pPr"))
143
+ lvl = f"lvl{int(ppr.get('lvl', '0')) + 1 if ppr is not None else 1}pPr"
144
+ dflt = ppr.find(qn("a:defRPr")) if ppr is not None else None
145
+ color = theme.solid(dflt) if dflt is not None else None
146
+ if color:
147
+ return color
148
+ chain, kind = _placeholder_chain(slide, sp)
149
+ for shape in chain:
150
+ node = shape.find(f".//{qn('p:txBody')}/{{{_A}}}lstStyle/{{{_A}}}{lvl}/{{{_A}}}defRPr")
151
+ color = theme.solid(node) if node is not None else None
152
+ if color:
153
+ return color
154
+ styles = slide.slide_layout.slide_master._element.find(qn("p:txStyles"))
155
+ group = "titleStyle" if kind in ("title", "ctrTitle") else ("bodyStyle" if kind else "otherStyle")
156
+ node = styles.find(f"{qn('p:' + group)}/{{{_A}}}{lvl}/{{{_A}}}defRPr") if styles is not None else None
157
+ color = theme.solid(node) if node is not None else None
158
+ if color:
159
+ return color
160
+ ref = sp.find(f"{qn('p:style')}/{{{_A}}}fontRef")
161
+ if ref is not None and len(ref):
162
+ return theme.resolve(ref[0])
163
+ return theme.resolve(_synthetic("tx1"))
164
+
165
+
166
+ def _synthetic(name):
167
+ from lxml import etree
168
+
169
+ return etree.Element(f"{{{_A}}}schemeClr", val=name)
170
+
171
+
172
+ def _size_pt(run) -> float:
173
+ rpr = run.find(qn("a:rPr"))
174
+ sz = rpr.get("sz") if rpr is not None else None
175
+ return int(sz) / 100 if sz else 18.0 # PowerPoint's default body size
176
+
177
+
178
+ def _bold(run) -> bool:
179
+ rpr = run.find(qn("a:rPr"))
180
+ return rpr is not None and rpr.get("b") in ("1", "true")
181
+
182
+
183
+ def _through_groups(el, box):
184
+ """Maps a box from a group's own coordinate space out to the slide, group by group."""
185
+ x, y, w, h = box
186
+ parent = el.getparent()
187
+ while parent is not None and parent.tag == qn("p:grpSp"):
188
+ xf = parent.find(f"{qn('p:grpSpPr')}/{{{_A}}}xfrm")
189
+ if xf is not None and all(xf.find(f"{{{_A}}}{t}") is not None for t in ("off", "ext", "chOff", "chExt")):
190
+ g = {t: xf.find(f"{{{_A}}}{t}") for t in ("off", "ext", "chOff", "chExt")}
191
+ cw, ch = int(g["chExt"].get("cx")) or 1, int(g["chExt"].get("cy")) or 1
192
+ sx, sy = int(g["ext"].get("cx")) / cw, int(g["ext"].get("cy")) / ch
193
+ x = int(g["off"].get("x")) + (x - int(g["chOff"].get("x"))) * sx
194
+ y = int(g["off"].get("y")) + (y - int(g["chOff"].get("y"))) * sy
195
+ w, h = w * sx, h * sy
196
+ parent = parent.getparent()
197
+ return x, y, w, h
198
+
199
+
200
+ def _box(chain):
201
+ """(x, y, w, h) of a shape on the slide, taken from the first of its placeholder chain that sets one."""
202
+ for el in chain:
203
+ xfrm = el.find(f"{qn('p:spPr')}/{{{_A}}}xfrm")
204
+ if xfrm is not None and xfrm.find(f"{{{_A}}}off") is not None and xfrm.find(f"{{{_A}}}ext") is not None:
205
+ off, ext = xfrm.find(f"{{{_A}}}off"), xfrm.find(f"{{{_A}}}ext")
206
+ raw = (int(off.get("x")), int(off.get("y")), int(ext.get("cx")), int(ext.get("cy")))
207
+ return _through_groups(el, raw) if el is chain[0] else raw
208
+ return None
209
+
210
+
211
+ def _covers(el, point) -> bool:
212
+ box = _box([el])
213
+ if box is None:
214
+ return False
215
+ x, y, w, h = box
216
+ px, py = point
217
+ geom = el.find(f"{qn('p:spPr')}/{{{_A}}}prstGeom")
218
+ if geom is not None and geom.get("prst") == "ellipse":
219
+ return w > 0 and h > 0 and ((px - x - w / 2) / (w / 2)) ** 2 + ((py - y - h / 2) / (h / 2)) ** 2 <= 1
220
+ return x <= px <= x + w and y <= py <= y + h
221
+
222
+
223
+ def _behind(theme: _Theme, slide, sp, fallback):
224
+ """The colors a text shape sits on: the topmost filled shape beneath its center
225
+ (master, then layout, then earlier shapes on the slide), else the slide background."""
226
+ chain, _ = _placeholder_chain(slide, sp)
227
+ box = _box(chain)
228
+ if box is None:
229
+ return fallback
230
+ return _behind_point(theme, slide, sp, (box[0] + box[2] / 2, box[1] + box[3] / 2), fallback)
231
+
232
+
233
+ def _behind_point(theme: _Theme, slide, sp, point, fallback):
234
+ if point is None:
235
+ return fallback
236
+ found = fallback
237
+ layers = [slide.slide_layout.slide_master._element, slide.slide_layout._element]
238
+ for root in layers:
239
+ for cand in root.iter(qn("p:sp")):
240
+ if cand.find(f"{qn('p:nvSpPr')}/{qn('p:nvPr')}/{qn('p:ph')}") is not None:
241
+ continue
242
+ has, colors = _shape_fill(theme, cand)
243
+ if has and _covers(cand, point):
244
+ found = colors
245
+ for cand in slide._element.iter(qn("p:sp")):
246
+ if cand is sp:
247
+ break
248
+ has, colors = _shape_fill(theme, cand)
249
+ if has and _covers(cand, point):
250
+ found = colors
251
+ return found
252
+
253
+
254
+ def check_contrast(prs) -> tuple[list, int, int]:
255
+ theme = _Theme(prs)
256
+ findings, measured, unmeasured = [], 0, 0
257
+ for number, slide in enumerate(prs.slides, 1):
258
+ bg = _background(theme, slide)
259
+ seen: set = set()
260
+ for sp in slide._element.iter(qn("p:sp")):
261
+ own, fill = _shape_fill(theme, sp)
262
+ backdrop = fill if own else _behind(theme, slide, sp, bg)
263
+ for para in sp.iter(f"{{{_A}}}p"):
264
+ for run in para.findall(qn("a:r")):
265
+ text = (run.findtext(qn("a:t")) or "").strip()
266
+ if not text:
267
+ continue
268
+ fg = _run_color(theme, run, para, slide, sp)
269
+ if not fg or not backdrop:
270
+ unmeasured += 1
271
+ continue
272
+ measured += 1
273
+ ratio = min(contrast_ratio(fg, b) for b in backdrop)
274
+ size = _size_pt(run)
275
+ need = LARGE_MIN if size >= 18 or (size >= 14 and _bold(run)) else NORMAL_MIN
276
+ shown = ", ".join("#" + b for b in backdrop)
277
+ if ratio < need and (fg, tuple(backdrop), text[:30]) not in seen:
278
+ seen.add((fg, tuple(backdrop), text[:30]))
279
+ findings.append(
280
+ Finding(
281
+ number,
282
+ "contrast",
283
+ f"“{text[:50]}” is hard to read.",
284
+ f"Contrast {ratio:.1f}:1, needs {need}:1 (text #{fg} on {shown}).",
285
+ )
286
+ )
287
+ # table cells: each cell's own fill, else the table frame's backdrop
288
+ for frame in slide._element.iter(qn("p:graphicFrame")):
289
+ tbl = frame.find(f".//{{{_A}}}tbl")
290
+ if tbl is None:
291
+ continue
292
+ styled = tbl.find(f"{{{_A}}}tblPr/{{{_A}}}tableStyleId") is not None
293
+ xf = frame.find(qn("p:xfrm"))
294
+ where = None
295
+ if xf is not None:
296
+ off, ext = xf.find(f"{{{_A}}}off"), xf.find(f"{{{_A}}}ext")
297
+ where = (int(off.get("x")) + int(ext.get("cx")) // 2, int(off.get("y")) + int(ext.get("cy")) // 2)
298
+ frame_bg = _behind_point(theme, slide, frame, where, bg)
299
+ for cell in tbl.iter(f"{{{_A}}}tc"):
300
+ pr = cell.find(f"{{{_A}}}tcPr")
301
+ own = pr is not None and any(pr.find(f"{{{_A}}}{t}") is not None for t in ("solidFill", "gradFill", "blipFill", "pattFill"))
302
+ if pr is not None and pr.find(f"{{{_A}}}noFill") is not None:
303
+ own = False
304
+ backdrop = _fill_colors(theme, pr) if own else (None if styled else frame_bg)
305
+ for para in cell.iter(f"{{{_A}}}p"):
306
+ for run in para.findall(qn("a:r")):
307
+ text = (run.findtext(qn("a:t")) or "").strip()
308
+ if not text:
309
+ continue
310
+ rpr = run.find(qn("a:rPr"))
311
+ fg = theme.solid(rpr) if rpr is not None else None
312
+ if not fg and not styled:
313
+ fg = theme.resolve(_synthetic("tx1"))
314
+ if not fg or not backdrop:
315
+ unmeasured += 1
316
+ continue
317
+ measured += 1
318
+ ratio = min(contrast_ratio(fg, b) for b in backdrop)
319
+ size = _size_pt(run)
320
+ need = LARGE_MIN if size >= 18 or (size >= 14 and _bold(run)) else NORMAL_MIN
321
+ shown = ", ".join("#" + b for b in backdrop)
322
+ if ratio < need and (fg, tuple(backdrop), text[:30]) not in seen:
323
+ seen.add((fg, tuple(backdrop), text[:30]))
324
+ findings.append(
325
+ Finding(
326
+ number,
327
+ "contrast",
328
+ f"“{text[:50]}” is hard to read.",
329
+ f"Contrast {ratio:.1f}:1, needs {need}:1 (text #{fg} on {shown}).",
330
+ )
331
+ )
332
+ return findings, measured, unmeasured
333
+
334
+
335
+ # ---- reading order ---------------------------------------------------------------
336
+ def _meaningful(shape) -> bool:
337
+ el = shape._element
338
+ if el.tag in (qn("p:pic"), qn("p:graphicFrame"), qn("p:grpSp")):
339
+ return True
340
+ return el.tag == qn("p:sp") and bool(shape.has_text_frame and shape.text_frame.text.strip())
341
+
342
+
343
+ def check_reading_order(prs) -> list:
344
+ findings = []
345
+ for number, slide in enumerate(prs.slides, 1):
346
+ items = [s for s in slide.shapes if _meaningful(s) and None not in (s.top, s.height, s.left)]
347
+ title = slide.shapes.title
348
+ if title is not None and title.text_frame.text.strip() and items and items[0].shape_id != title.shape_id:
349
+ findings.append(
350
+ Finding(number, "reading_order", "The title is not read first.", f"A screen reader starts with “{_label(items[0])}”.")
351
+ )
352
+ continue
353
+ for i, first in enumerate(items):
354
+ above = next((later for later in items[i + 1 :] if later.top + later.height <= first.top), None)
355
+ if above is not None:
356
+ findings.append(
357
+ Finding(
358
+ number,
359
+ "reading_order",
360
+ "Reading order does not follow the layout.",
361
+ f"“{_label(first)}” is read before “{_label(above)}”, which sits above it.",
362
+ )
363
+ )
364
+ break
365
+ return findings
366
+
367
+
368
+ def _label(shape) -> str:
369
+ text = shape.text_frame.text.strip() if shape.has_text_frame else ""
370
+ return (text or shape.name)[:40]
371
+
372
+
373
+ # ---- entry point -----------------------------------------------------------------
374
+ def audit_deck(path: Path) -> Report:
375
+ prs = open_presentation(path)
376
+ contrast, measured, unmeasured = check_contrast(prs)
377
+ return Report(contrast + check_reading_order(prs), measured, unmeasured)
cappv1/audit_color.py ADDED
@@ -0,0 +1,87 @@
1
+ """Color maths and theme colors for the read-only accessibility check (see audit.py)."""
2
+
3
+ from pptx.oxml.ns import qn
4
+
5
+ _A = "http://schemas.openxmlformats.org/drawingml/2006/main"
6
+
7
+
8
+ # ---- colors ----------------------------------------------------------------------
9
+ def _lum(hex6: str) -> float:
10
+ chan = [int(hex6[i : i + 2], 16) / 255 for i in (0, 2, 4)]
11
+ chan = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in chan]
12
+ return 0.2126 * chan[0] + 0.7152 * chan[1] + 0.0722 * chan[2]
13
+
14
+
15
+ def contrast_ratio(a: str, b: str) -> float:
16
+ hi, lo = sorted((_lum(a), _lum(b)), reverse=True)
17
+ return (hi + 0.05) / (lo + 0.05)
18
+
19
+
20
+ def _hls(hex6):
21
+ import colorsys
22
+
23
+ r, g, b = (int(hex6[i : i + 2], 16) / 255 for i in (0, 2, 4))
24
+ return colorsys.rgb_to_hls(r, g, b)
25
+
26
+
27
+ def _from_hls(h, l, s) -> str:
28
+ import colorsys
29
+
30
+ r, g, b = colorsys.hls_to_rgb(h, min(max(l, 0), 1), s)
31
+ return "".join(f"{round(c * 255):02X}" for c in (r, g, b))
32
+
33
+
34
+ class _Theme:
35
+ def __init__(self, prs) -> None:
36
+ self.colors: dict[str, str] = {}
37
+ self.fills: list = []
38
+ master = prs.slide_master
39
+ for rel in master.part.rels.values():
40
+ if rel.reltype.endswith("/theme"):
41
+ from lxml import etree
42
+
43
+ root = etree.fromstring(rel.target_part.blob)
44
+ scheme = root.find(f".//{{{_A}}}clrScheme")
45
+ for el in scheme if scheme is not None else []:
46
+ child = el[0]
47
+ val = child.get("val") if child.tag.endswith("srgbClr") else child.get("lastClr")
48
+ if val:
49
+ self.colors[el.tag.split("}")[1]] = val.upper()
50
+ lst = root.find(f".//{{{_A}}}bgFillStyleLst")
51
+ self.fills = list(lst) if lst is not None else []
52
+ cmap = master._element.find(qn("p:clrMap"))
53
+ self.map = dict(cmap.attrib) if cmap is not None else {"tx1": "dk1", "bg1": "lt1", "tx2": "dk2", "bg2": "lt2"}
54
+
55
+ def resolve(self, color_el, phclr: str | None = None) -> str | None:
56
+ """A color element (srgbClr, schemeClr, ...) to 'RRGGBB', or None if it can't be judged."""
57
+ tag = color_el.tag.split("}")[1]
58
+ if tag == "srgbClr":
59
+ base = color_el.get("val").upper()
60
+ elif tag == "schemeClr":
61
+ val = color_el.get("val")
62
+ if val == "phClr":
63
+ base = phclr
64
+ else:
65
+ base = self.colors.get(self.map.get(val, val))
66
+ else:
67
+ return None
68
+ if not base or len(base) != 6:
69
+ return None
70
+ for mod in color_el:
71
+ name, val = mod.tag.split("}")[1], mod.get("val")
72
+ if name == "alpha" and int(val) < 100000:
73
+ return None # semi-transparent: what shows through is unknown
74
+ if name in ("lumMod", "lumOff"):
75
+ continue
76
+ if name in ("tint", "shade", "satMod", "hueMod", "satOff", "hueOff", "comp", "inv", "gray"):
77
+ return None
78
+ h, l, s = _hls(base)
79
+ mod = next((int(m.get("val")) for m in color_el if m.tag.endswith("}lumMod")), 100000)
80
+ off = next((int(m.get("val")) for m in color_el if m.tag.endswith("}lumOff")), 0)
81
+ if mod != 100000 or off:
82
+ return _from_hls(h, l * mod / 100000 + off / 100000, s)
83
+ return base
84
+
85
+ def solid(self, fill_parent, phclr=None) -> str | None:
86
+ sf = fill_parent.find(f"{{{_A}}}solidFill") if fill_parent is not None else None
87
+ return self.resolve(sf[0], phclr) if sf is not None and len(sf) else None