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.
- cappv1/.env.example +33 -0
- cappv1/CHANGELOG.md +140 -0
- cappv1/__init__.py +0 -0
- cappv1/__main__.py +5 -0
- cappv1/audit.py +377 -0
- cappv1/audit_color.py +87 -0
- cappv1/config.py +101 -0
- cappv1/doctor.py +216 -0
- cappv1/docx_check.py +234 -0
- cappv1/docx_pipeline.py +300 -0
- cappv1/docx_reader.py +358 -0
- cappv1/docx_structure.py +210 -0
- cappv1/events.py +31 -0
- cappv1/home.py +23 -0
- cappv1/image_filter.py +26 -0
- cappv1/llm_client.py +315 -0
- cappv1/llm_prompts.py +109 -0
- cappv1/llm_providers.py +87 -0
- cappv1/logsetup.py +63 -0
- cappv1/main.py +77 -0
- cappv1/models.py +147 -0
- cappv1/outputs.py +79 -0
- cappv1/pipeline.py +544 -0
- cappv1/pptx_reader.py +458 -0
- cappv1/services/__init__.py +7 -0
- cappv1/services/audit.py +27 -0
- cappv1/services/envfile.py +36 -0
- cappv1/services/errors.py +9 -0
- cappv1/services/files.py +191 -0
- cappv1/services/health.py +80 -0
- cappv1/services/models.py +86 -0
- cappv1/services/runs.py +181 -0
- cappv1/services/settings.py +143 -0
- cappv1/services/storage.py +65 -0
- cappv1/services/workspace.py +39 -0
- cappv1/state.py +73 -0
- cappv1/tui/__init__.py +1 -0
- cappv1/tui/__main__.py +5 -0
- cappv1/tui/anim.py +64 -0
- cappv1/tui/commands.py +382 -0
- cappv1/tui/common.py +31 -0
- cappv1/tui/menu.py +73 -0
- cappv1/tui/runview.py +187 -0
- cappv1/tui/session.py +277 -0
- cappv1/tui/setup_flow.py +211 -0
- cappv1/tui/theme.py +19 -0
- cappv1/version.py +4 -0
- cappv1/web/__init__.py +5 -0
- cappv1/web/api.py +263 -0
- cappv1/web/changelog.py +79 -0
- cappv1/web/pages/404.html +4 -0
- cappv1/web/pages/about.html +27 -0
- cappv1/web/pages/appearance.html +31 -0
- cappv1/web/pages/changelog.html +4 -0
- cappv1/web/pages/data.html +19 -0
- cappv1/web/pages/faq.html +55 -0
- cappv1/web/pages/files.html +57 -0
- cappv1/web/pages/health.html +15 -0
- cappv1/web/pages/how-it-works.html +44 -0
- cappv1/web/pages/index.html +92 -0
- cappv1/web/pages/privacy.html +78 -0
- cappv1/web/pages/services.html +22 -0
- cappv1/web/server.py +341 -0
- cappv1/web/static/app.css +287 -0
- cappv1/web/static/app.js +73 -0
- cappv1/web/static/appearance.js +23 -0
- cappv1/web/static/dashboard.js +77 -0
- cappv1/web/static/data.js +34 -0
- cappv1/web/static/favicon.svg +6 -0
- cappv1/web/static/files.js +506 -0
- cappv1/web/static/fonts/NOTICE.txt +3 -0
- cappv1/web/static/fonts/ah-400.woff2 +0 -0
- cappv1/web/static/fonts/ah-700.woff2 +0 -0
- cappv1/web/static/health.js +31 -0
- cappv1/web/static/icon-192.png +0 -0
- cappv1/web/static/icon-512.png +0 -0
- cappv1/web/static/manifest.webmanifest +15 -0
- cappv1/web/static/services.js +122 -0
- cappv1/web/static/shell.js +12 -0
- cappv1/web/static/theme.js +8 -0
- cappv1/web/templates/app.html +75 -0
- cappv1-1.7.0.dist-info/METADATA +138 -0
- cappv1-1.7.0.dist-info/RECORD +86 -0
- cappv1-1.7.0.dist-info/WHEEL +4 -0
- cappv1-1.7.0.dist-info/entry_points.txt +2 -0
- 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
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
|