fancy-dark-slide 0.1.0__tar.gz
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.
- fancy_dark_slide-0.1.0/.gitattributes +23 -0
- fancy_dark_slide-0.1.0/.github/workflows/ci.yml +79 -0
- fancy_dark_slide-0.1.0/.github/workflows/publish.yml +87 -0
- fancy_dark_slide-0.1.0/.gitignore +13 -0
- fancy_dark_slide-0.1.0/AGENTS.md +170 -0
- fancy_dark_slide-0.1.0/CHANGELOG.md +71 -0
- fancy_dark_slide-0.1.0/CLAUDE.md +1 -0
- fancy_dark_slide-0.1.0/LICENSE +21 -0
- fancy_dark_slide-0.1.0/PKG-INFO +238 -0
- fancy_dark_slide-0.1.0/README.md +194 -0
- fancy_dark_slide-0.1.0/art/fancy-ui.svg +26 -0
- fancy_dark_slide-0.1.0/pyproject.toml +52 -0
- fancy_dark_slide-0.1.0/scripts/php_imagesize.php +41 -0
- fancy_dark_slide-0.1.0/scripts/php_read.php +44 -0
- fancy_dark_slide-0.1.0/scripts/php_tobytes.php +48 -0
- fancy_dark_slide-0.1.0/src/dark_slide/__init__.py +81 -0
- fancy_dark_slide-0.1.0/src/dark_slide/agent.py +163 -0
- fancy_dark_slide-0.1.0/src/dark_slide/exceptions.py +21 -0
- fancy_dark_slide-0.1.0/src/dark_slide/helpers/__init__.py +1 -0
- fancy_dark_slide-0.1.0/src/dark_slide/helpers/chart_translator.py +201 -0
- fancy_dark_slide-0.1.0/src/dark_slide/helpers/color.py +85 -0
- fancy_dark_slide-0.1.0/src/dark_slide/helpers/emu.py +73 -0
- fancy_dark_slide-0.1.0/src/dark_slide/helpers/markdown_inline.py +132 -0
- fancy_dark_slide-0.1.0/src/dark_slide/helpers/syntax_highlighter.py +258 -0
- fancy_dark_slide-0.1.0/src/dark_slide/helpers/xml.py +44 -0
- fancy_dark_slide-0.1.0/src/dark_slide/py.typed +0 -0
- fancy_dark_slide-0.1.0/src/dark_slide/reader/__init__.py +5 -0
- fancy_dark_slide-0.1.0/src/dark_slide/reader/pptx_reader.py +630 -0
- fancy_dark_slide-0.1.0/src/dark_slide/schema/__init__.py +7 -0
- fancy_dark_slide-0.1.0/src/dark_slide/schema/repairer.py +123 -0
- fancy_dark_slide-0.1.0/src/dark_slide/schema/schema.py +215 -0
- fancy_dark_slide-0.1.0/src/dark_slide/schema/types.py +187 -0
- fancy_dark_slide-0.1.0/src/dark_slide/schema/validator.py +219 -0
- fancy_dark_slide-0.1.0/src/dark_slide/util.py +206 -0
- fancy_dark_slide-0.1.0/src/dark_slide/writer/__init__.py +5 -0
- fancy_dark_slide-0.1.0/src/dark_slide/writer/pptx_writer.py +2344 -0
- fancy_dark_slide-0.1.0/tests/__init__.py +1 -0
- fancy_dark_slide-0.1.0/tests/_oracle.py +126 -0
- fancy_dark_slide-0.1.0/tests/conformance/__init__.py +1 -0
- fancy_dark_slide-0.1.0/tests/conformance/loader.py +214 -0
- fancy_dark_slide-0.1.0/tests/conformance/test_shared_suites.py +104 -0
- fancy_dark_slide-0.1.0/tests/conftest.py +39 -0
- fancy_dark_slide-0.1.0/tests/fixtures.py +693 -0
- fancy_dark_slide-0.1.0/tests/test_agent.py +436 -0
- fancy_dark_slide-0.1.0/tests/test_animation_timing.py +301 -0
- fancy_dark_slide-0.1.0/tests/test_determinism.py +24 -0
- fancy_dark_slide-0.1.0/tests/test_helpers.py +603 -0
- fancy_dark_slide-0.1.0/tests/test_hyperlinks.py +119 -0
- fancy_dark_slide-0.1.0/tests/test_image_size.py +145 -0
- fancy_dark_slide-0.1.0/tests/test_package_validity.py +132 -0
- fancy_dark_slide-0.1.0/tests/test_parity_php.py +101 -0
- fancy_dark_slide-0.1.0/tests/test_reader_parity_php.py +116 -0
- fancy_dark_slide-0.1.0/tests/test_relationship_id_collision.py +139 -0
- fancy_dark_slide-0.1.0/tests/test_roundtrip.py +270 -0
- fancy_dark_slide-0.1.0/tests/test_v04_features.py +424 -0
- fancy_dark_slide-0.1.0/tests/test_version_is_single_sourced.py +55 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Line endings are part of the contract here, so they are pinned rather than
|
|
2
|
+
# left to a clone's `core.autocrlf`.
|
|
3
|
+
#
|
|
4
|
+
# Two reasons, both concrete:
|
|
5
|
+
#
|
|
6
|
+
# 1. The writers are string builders and the OOXML they emit is compared
|
|
7
|
+
# byte-for-byte against the PHP reference. A source file checked out with
|
|
8
|
+
# CRLF changes the bytes of any multi-line literal in it.
|
|
9
|
+
# 2. `holy-sheet`'s tool-definition JSON is pinned by a cross-repo SHA-256
|
|
10
|
+
# that is taken over CRLF-NORMALISED text. A Windows checkout hashes
|
|
11
|
+
# differently from the same file on Linux, which reads as a drifted schema
|
|
12
|
+
# when nothing has drifted at all. It has already cost one investigation.
|
|
13
|
+
#
|
|
14
|
+
# The markdown fixtures matter for the same reason: `last-word`'s markdown
|
|
15
|
+
# bridge is asserted byte-exact with LF newlines.
|
|
16
|
+
* text=auto eol=lf
|
|
17
|
+
|
|
18
|
+
*.docx binary
|
|
19
|
+
*.xlsx binary
|
|
20
|
+
*.pptx binary
|
|
21
|
+
*.png binary
|
|
22
|
+
*.jpg binary
|
|
23
|
+
*.jpeg binary
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
# Least-privilege: CI only reads the repo.
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
# Third-party dependency allowlist. Cheap, and failing here is cheap --
|
|
23
|
+
# the same check gates the release, so a PR that adds an unapproved or
|
|
24
|
+
# abandoned dependency finds out now rather than at tag time.
|
|
25
|
+
# The allowlist is FETCHED, never vendored: one source, no copies to drift.
|
|
26
|
+
- name: Fetch the third-party allowlist
|
|
27
|
+
uses: actions/checkout@v4
|
|
28
|
+
with:
|
|
29
|
+
repository: Particle-Academy/.github
|
|
30
|
+
path: .third-party-allowlist
|
|
31
|
+
persist-credentials: false
|
|
32
|
+
|
|
33
|
+
- name: Third-party dependency allowlist
|
|
34
|
+
run: |
|
|
35
|
+
node .third-party-allowlist/third-party/check.mjs --repo .
|
|
36
|
+
# Delete it. A fetched checkout left lying in the workspace is not
|
|
37
|
+
# inert: vitest globbed the checker's own tests out of it and failed
|
|
38
|
+
# a package whose code was fine. Removed with node, not rm -rf,
|
|
39
|
+
# because some of these matrices run on windows-latest where a
|
|
40
|
+
# multi-line run: block is PowerShell and rm -rf is not a command.
|
|
41
|
+
node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})"
|
|
42
|
+
|
|
43
|
+
- uses: actions/setup-python@v5
|
|
44
|
+
with:
|
|
45
|
+
python-version: ${{ matrix.python-version }}
|
|
46
|
+
|
|
47
|
+
# PHP is REQUIRED, not optional. tests/test_parity_php.py is the
|
|
48
|
+
# cross-runtime writer guarantee and it FAILS when php is absent rather
|
|
49
|
+
# than skipping - a skip here is a green build with zero parity coverage,
|
|
50
|
+
# which is exactly how two suites in this org reported success over
|
|
51
|
+
# nothing for months.
|
|
52
|
+
- uses: shivammathur/setup-php@v2
|
|
53
|
+
with:
|
|
54
|
+
php-version: "8.4"
|
|
55
|
+
coverage: none
|
|
56
|
+
extensions: mbstring, zip
|
|
57
|
+
|
|
58
|
+
- name: Check out the PHP dark-slide
|
|
59
|
+
uses: actions/checkout@v4
|
|
60
|
+
with:
|
|
61
|
+
repository: Particle-Academy/dark-slide
|
|
62
|
+
path: .php-dark-slide
|
|
63
|
+
|
|
64
|
+
- name: Check out fancy-conformance
|
|
65
|
+
uses: actions/checkout@v4
|
|
66
|
+
with:
|
|
67
|
+
repository: Particle-Academy/fancy-conformance
|
|
68
|
+
path: .fancy-conformance
|
|
69
|
+
|
|
70
|
+
- name: Install
|
|
71
|
+
run: |
|
|
72
|
+
python -m pip install --upgrade pip
|
|
73
|
+
pip install -e ".[dev]"
|
|
74
|
+
|
|
75
|
+
- name: Test
|
|
76
|
+
run: python -m pytest
|
|
77
|
+
env:
|
|
78
|
+
DARK_SLIDE_PHP_SRC: ${{ github.workspace }}/.php-dark-slide/src
|
|
79
|
+
FANCY_CONFORMANCE_ROOT: ${{ github.workspace }}/.fancy-conformance
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions: {}
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
# Build and publish are separate jobs so `id-token: write` is never held by
|
|
11
|
+
# the job that executes build code.
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
persist-credentials: false
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.x"
|
|
21
|
+
|
|
22
|
+
# The one documentation failure that cannot be repaired later: once the
|
|
23
|
+
# sdist is on PyPI, whoever upgraded into it has no way to learn what
|
|
24
|
+
# changed. Checked before anything is built.
|
|
25
|
+
- name: Require a CHANGELOG entry for this tag
|
|
26
|
+
run: |
|
|
27
|
+
version="${GITHUB_REF_NAME#v}"
|
|
28
|
+
grep -q "^## \[${version}\]" CHANGELOG.md || {
|
|
29
|
+
echo "No CHANGELOG.md entry for ${version}."
|
|
30
|
+
exit 1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Release gate: third-party dependencies must be approved and still
|
|
34
|
+
# maintained BEFORE the tarball reaches the registry. Once it is
|
|
35
|
+
# published, an unapproved or abandoned dependency has already shipped
|
|
36
|
+
# to everyone who upgrades -- the same reason the changelog gate sits
|
|
37
|
+
# here. The allowlist is FETCHED, never vendored: one source of truth,
|
|
38
|
+
# so there is no per-repo copy that can drift out of agreement with it.
|
|
39
|
+
- name: Fetch the third-party allowlist
|
|
40
|
+
uses: actions/checkout@v4
|
|
41
|
+
with:
|
|
42
|
+
repository: Particle-Academy/.github
|
|
43
|
+
path: .third-party-allowlist
|
|
44
|
+
persist-credentials: false
|
|
45
|
+
|
|
46
|
+
- name: Third-party dependency allowlist
|
|
47
|
+
run: |
|
|
48
|
+
node .third-party-allowlist/third-party/check.mjs --repo .
|
|
49
|
+
# Delete it. A fetched checkout left lying in the workspace is not
|
|
50
|
+
# inert: vitest globbed the checker's own tests out of it and failed
|
|
51
|
+
# a package whose code was fine. Removed with node, not rm -rf,
|
|
52
|
+
# because some of these matrices run on windows-latest where a
|
|
53
|
+
# multi-line run: block is PowerShell and rm -rf is not a command.
|
|
54
|
+
node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})"
|
|
55
|
+
|
|
56
|
+
- name: Require the version to match the tag
|
|
57
|
+
run: |
|
|
58
|
+
version="${GITHUB_REF_NAME#v}"
|
|
59
|
+
grep -q "^version = \"${version}\"$" pyproject.toml || {
|
|
60
|
+
echo "pyproject.toml version does not match tag ${GITHUB_REF_NAME}."
|
|
61
|
+
exit 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
- run: python -m pip install --upgrade build && python -m build
|
|
65
|
+
- uses: actions/upload-artifact@v4
|
|
66
|
+
with:
|
|
67
|
+
name: dists
|
|
68
|
+
path: dist/
|
|
69
|
+
|
|
70
|
+
publish:
|
|
71
|
+
needs: build
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
environment:
|
|
74
|
+
name: pypi
|
|
75
|
+
url: https://pypi.org/p/fancy-flow
|
|
76
|
+
permissions:
|
|
77
|
+
id-token: write # mandatory for Trusted Publishing
|
|
78
|
+
steps:
|
|
79
|
+
- uses: actions/download-artifact@v4
|
|
80
|
+
with:
|
|
81
|
+
name: dists
|
|
82
|
+
path: dist/
|
|
83
|
+
# Pinned by SHA, not by tag: a mutable reference in exactly this position
|
|
84
|
+
# is what the April 2026 supply-chain compromises exploited.
|
|
85
|
+
# v1.14.2 -- no `password:` (that is the point) and attestations default
|
|
86
|
+
# to true.
|
|
87
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# AGENTS.md — dark-slide (Python)
|
|
2
|
+
|
|
3
|
+
This file describes **this repository's code**: its API, its invariants, and the
|
|
4
|
+
traps in it. Process rules — release lifecycle, publishing, version policy,
|
|
5
|
+
backports — live in the envelope's `AGENTS.md` and must never be copied here; a
|
|
6
|
+
copy on a maintenance branch would freeze a rule that has since changed, with
|
|
7
|
+
nothing to flag it.
|
|
8
|
+
|
|
9
|
+
## What this is
|
|
10
|
+
|
|
11
|
+
A `.pptx` writer + reader with **no runtime dependencies, permanently**. It is
|
|
12
|
+
one of three engines that must produce the same document from the same deck:
|
|
13
|
+
|
|
14
|
+
| | |
|
|
15
|
+
|---|---|
|
|
16
|
+
| PHP | `particle-academy/dark-slide` — **the reference** |
|
|
17
|
+
| Node | `@particle-academy/dark-slide` |
|
|
18
|
+
| Python | this repo |
|
|
19
|
+
|
|
20
|
+
The deck model is plain `dict`s and the `Validator` is the gate. `schema/types.py`
|
|
21
|
+
holds `TypedDict`s for editor support only; nothing enforces them at runtime, and
|
|
22
|
+
nothing should. Loose agent JSON is the *input format*, and
|
|
23
|
+
`validate_and_repair()` exists to rescue it.
|
|
24
|
+
|
|
25
|
+
## The invariant everything else serves
|
|
26
|
+
|
|
27
|
+
> **`to_bytes()` must emit byte-identical OOXML parts to the PHP engine.**
|
|
28
|
+
|
|
29
|
+
`tests/test_parity_php.py` runs the PHP writer as a subprocess and diffs every
|
|
30
|
+
part for every fixture. That is the definition of done for any writer change.
|
|
31
|
+
It follows that:
|
|
32
|
+
|
|
33
|
+
- **Never build XML with `ElementTree` on the write side.** Attribute order,
|
|
34
|
+
self-closing style, and the absence of inter-element whitespace are all part
|
|
35
|
+
of the output. `helpers/xml.py` is an escaper, not a serialiser, and the
|
|
36
|
+
writer concatenates strings.
|
|
37
|
+
- **`helpers/xml.text` and `helpers/xml.attr` escape different sets**, and the
|
|
38
|
+
asymmetry is load-bearing: `text` leaves the apostrophe alone, `attr` writes
|
|
39
|
+
`'` (the `ENT_XML1` entity — *not* `'`, whatever a spec says;
|
|
40
|
+
the parity suite checks it against the engine).
|
|
41
|
+
- **Which parts exist is contract.** No `notesMasters/`, no `presProps.xml`, no
|
|
42
|
+
`tableStyles.xml`. All eight `slideLayout` parts always ship. Notes parts are
|
|
43
|
+
numbered by SLIDE, so the sequence has gaps.
|
|
44
|
+
- **The zip container is NOT compared and never can be.** PHP writes DEFLATE
|
|
45
|
+
with real mtimes through `ZipArchive`; this writes STORE with a fixed
|
|
46
|
+
1980-01-01 DOS date. The comparison unzips both and diffs parts, which is the
|
|
47
|
+
real contract anyway — a reader sees parts, never the compression.
|
|
48
|
+
|
|
49
|
+
## Traps
|
|
50
|
+
|
|
51
|
+
### 1. `round()` is the wrong function here. Always.
|
|
52
|
+
|
|
53
|
+
Python rounds half to **even**; PHP rounds half **away from zero**. Every
|
|
54
|
+
coordinate in every deck is `round(fraction * 9144000)`, so this is not a corner
|
|
55
|
+
case — it is a one-EMU shift in thousands of attributes.
|
|
56
|
+
|
|
57
|
+
Use `helpers.emu.php_round`. Nothing in the package may call the builtin, and
|
|
58
|
+
`test_no_module_in_the_package_calls_the_builtin_round` parses the AST to prove
|
|
59
|
+
it. The `roundingTies` fixture pins two real ties (`3/128 × 9144000` and
|
|
60
|
+
`3/8 × 5143500`, both exactly `.5` over an even integer) against the oracle.
|
|
61
|
+
|
|
62
|
+
The one exception is documented at its site: `_num_str`'s `"%.6f"` is safe
|
|
63
|
+
because **no IEEE-754 double is an exact tie at six decimal places** — that
|
|
64
|
+
would require `5·10⁻⁷` to be a dyadic rational, and it is not — so the rounding
|
|
65
|
+
mode cannot matter there.
|
|
66
|
+
|
|
67
|
+
### 2. PHP's loose types are reproduced on purpose (`util.py`)
|
|
68
|
+
|
|
69
|
+
Five of them change emitted bytes, and Python's instincts are wrong on all five:
|
|
70
|
+
|
|
71
|
+
- `php_string(True)` is `"1"`, `php_string(12.0)` is `"12"`, and
|
|
72
|
+
`php_string(1/3)` is `"0.33333333333333"` (precision 14, not `repr`).
|
|
73
|
+
- `php_truthy("0")` is **False**. A slide whose `notes` is `"0"` gets no notes
|
|
74
|
+
part; `bool("0")` would give it one.
|
|
75
|
+
- `is_numeric` follows PHP: `"1e5"` is numeric and worth 100000, `" 12 "` is
|
|
76
|
+
numeric with whitespace on both ends.
|
|
77
|
+
- `php_float("12abc")` is `12.0`, not an error.
|
|
78
|
+
- `php_json_encode` uses compact separators and escapes `/` as `\/`.
|
|
79
|
+
|
|
80
|
+
### 3. String indexing is a THIRD scheme, and it agrees by accident
|
|
81
|
+
|
|
82
|
+
PHP indexes the markdown tokenizer and the syntax highlighter by **byte**; the
|
|
83
|
+
Node port by **UTF-16 code unit**; Python by **codepoint**. All three agree, and
|
|
84
|
+
the reason is worth keeping in mind before any "tidy": every cut is at an ASCII
|
|
85
|
+
marker, and no UTF-8 continuation byte or UTF-16 surrogate half can equal an
|
|
86
|
+
ASCII byte. The `unicodeText` fixture and the `fancy-conformance`
|
|
87
|
+
`shared/strings` suite pin it. Changing either tokenizer to slice differently
|
|
88
|
+
would be invisible in every existing test but this one.
|
|
89
|
+
|
|
90
|
+
### 4. Read XML with a parser — but reject DOCTYPE first
|
|
91
|
+
|
|
92
|
+
`reader/pptx_reader.py` uses `xml.etree.ElementTree` and matches on LOCAL names,
|
|
93
|
+
ignoring namespace binding. That is fine: nothing is serialised on the read
|
|
94
|
+
side, so no byte contract exists to protect.
|
|
95
|
+
|
|
96
|
+
`_parse_xml` refuses any input containing `<!DOCTYPE` **before the parser sees
|
|
97
|
+
it**. A `.pptx` never legitimately carries one, and the input is a file someone
|
|
98
|
+
uploaded.
|
|
99
|
+
|
|
100
|
+
### 5. `allow_http_images` defaults to `False`, and stays that way
|
|
101
|
+
|
|
102
|
+
Fetching a URL named inside a document is an SSRF surface. `data:` URIs,
|
|
103
|
+
`file://` and local paths work unconditionally; `http(s)` needs
|
|
104
|
+
`{"allow_http_images": True}` from the caller. The `imageFallback` fixture
|
|
105
|
+
asserts the default emits an `[image: …]` placeholder and makes no request.
|
|
106
|
+
|
|
107
|
+
### 6. Determinism is required, so the clock is an INPUT
|
|
108
|
+
|
|
109
|
+
`test_determinism.py` asserts byte stability, and `fancy-conformance` treats a
|
|
110
|
+
determinism flag as a precondition for a writer suite. PHP stamps
|
|
111
|
+
`docProps/core.xml` with `gmdate()` at write time and offers no way to pin it,
|
|
112
|
+
so this port reads `metadata.created` / `metadata.modified` when the deck
|
|
113
|
+
supplies them and falls back to `EPOCH_TIMESTAMP` otherwise. That is the ONE
|
|
114
|
+
entry in `KNOWN_DIVERGENT_PARTS`, and it names the reason.
|
|
115
|
+
|
|
116
|
+
### 7. The rel-id collision is reproduced, not fixed
|
|
117
|
+
|
|
118
|
+
Image relationship ids come from a **global** media counter, while a slide's
|
|
119
|
+
own rels start at `rId1` = layout and `rId2` = notesSlide. So one image on one
|
|
120
|
+
slide already emits two `<Relationship Id="rId1">` entries. Relationship ids
|
|
121
|
+
must be unique within a part, so the package is malformed — and every `.pptx`
|
|
122
|
+
with an image that any of the three engines has ever written carries it.
|
|
123
|
+
|
|
124
|
+
**Do not fix it here.** Fixing one engine alone breaks part-level parity, which
|
|
125
|
+
is the only cross-runtime guarantee the trio has; the ruling is one coordinated
|
|
126
|
+
release across every engine. The allocation site carries a `RULING PENDING:`
|
|
127
|
+
comment, the `imageRelIds` fixture pins the current bytes, and
|
|
128
|
+
`tests/test_relationship_id_collision.py` asserts the defect is still present —
|
|
129
|
+
so the day the engines are fixed together, it fails and gets deleted.
|
|
130
|
+
|
|
131
|
+
### 8. Python never casts a deciding vote on an open ruling
|
|
132
|
+
|
|
133
|
+
Where the PHP and Node engines already disagree, **this port follows PHP**, so
|
|
134
|
+
the tally stays 2-1 in the direction already recorded rather than becoming a
|
|
135
|
+
three-way split. The live ones, all with tests:
|
|
136
|
+
|
|
137
|
+
| Behaviour | PHP (and this port) | Node |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `chart.mode` default | `"png"` — a pre-rendered `data:` image wins over a translatable `option` | always tries native translation first |
|
|
140
|
+
| `{"id": null}` in the validator | no error (`isset()` is false for null) | flagged as a type error |
|
|
141
|
+
| non-base64 `data:` URIs | percent-decoded to **bytes** | re-encoded as UTF-8, corrupting binary |
|
|
142
|
+
| intrinsic size of a WebP / BMP | read, so `fit: cover` crops correctly | unread, so it stretches |
|
|
143
|
+
| `option.categories` with no `xAxis` | ignored (a shared wart, pinned) | same |
|
|
144
|
+
|
|
145
|
+
## Layout
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
src/dark_slide/
|
|
149
|
+
__init__.py the public façade
|
|
150
|
+
agent.py the Agent surface as module-level functions
|
|
151
|
+
exceptions.py SchemaException (carries the structured error list)
|
|
152
|
+
util.py PHP loose-typing semantics, written down once
|
|
153
|
+
schema/ schema.py · validator.py · repairer.py · types.py
|
|
154
|
+
writer/pptx_writer.py string building; the byte contract lives here
|
|
155
|
+
reader/pptx_reader.py ElementTree; best-effort, degrades rather than raises
|
|
156
|
+
helpers/ xml · color · emu (php_round) · markdown_inline
|
|
157
|
+
· syntax_highlighter · chart_translator
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Running the suite
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
PHP_BIN='/path/to/php' python -m pytest
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`pythonpath = ["src"]` is set, so a bare checkout with nothing but pytest runs
|
|
167
|
+
it. **A missing PHP is a skip locally and a hard failure under `CI`** — a
|
|
168
|
+
parity suite that quietly stops comparing anything reads exactly like one that
|
|
169
|
+
compares everything, which is how two sibling suites reported green over zero
|
|
170
|
+
coverage for months.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
**Pre-1.0: breaking changes land in MINOR releases.** Until `1.0.0` a bumped
|
|
9
|
+
minor may change an API, so read the entry before upgrading — the version
|
|
10
|
+
number cannot make a promise the 0.x range does not allow it to keep.
|
|
11
|
+
|
|
12
|
+
## [Unreleased]
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-08-18
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Initial release: the Python member of the `dark-slide` trio.** A
|
|
19
|
+
zero-dependency `.pptx` writer and reader, mirroring PHP
|
|
20
|
+
`particle-academy/dark-slide` and Node `@particle-academy/dark-slide`. The
|
|
21
|
+
same deck goes in and the same document comes out, whichever backend runs it.
|
|
22
|
+
|
|
23
|
+
- **Byte-level writer parity with the PHP engine, as a test result.** The suite
|
|
24
|
+
drives the PHP writer as a subprocess and diffs every OOXML part for every
|
|
25
|
+
fixture. One part is excused, with the reason recorded in
|
|
26
|
+
`KNOWN_DIVERGENT_PARTS`: `docProps/core.xml` carries a write-time timestamp
|
|
27
|
+
PHP offers no way to pin, so this port takes the document date from
|
|
28
|
+
`metadata.created` / `metadata.modified` and is byte-stable instead. Every
|
|
29
|
+
other byte of that part matches.
|
|
30
|
+
|
|
31
|
+
- **Structural reader parity with the PHP engine.** The same `.pptx` handed to
|
|
32
|
+
both readers recovers the same deck — compared structurally, with only the
|
|
33
|
+
volatile import `id` and the PHP empty-array/empty-object ambiguity
|
|
34
|
+
normalised away.
|
|
35
|
+
|
|
36
|
+
- **The Agent surface** as module-level functions: `validate`,
|
|
37
|
+
`validate_and_repair`, `to_bytes`, `write`, `read`, `from_bytes`, `describe`,
|
|
38
|
+
`json_schema`, `version`. `write` is synchronous (PHP's is; Node's is async
|
|
39
|
+
only because browsers have no synchronous filesystem) and `read` accepts
|
|
40
|
+
**both bytes and a path**, resolving a real PHP↔Node divergence rather than
|
|
41
|
+
picking a side.
|
|
42
|
+
|
|
43
|
+
- **`php_round`, and a test that forbids the builtin.** Python rounds half to
|
|
44
|
+
even and PHP rounds half away from zero, and every coordinate in a deck is a
|
|
45
|
+
`round(fraction × 9144000)`. An AST walk over the package fails the build if
|
|
46
|
+
`round()` is called anywhere, and the `roundingTies` fixture pins two real
|
|
47
|
+
ties against the PHP oracle.
|
|
48
|
+
|
|
49
|
+
- **The image-header sniffer covers PNG, JPEG, GIF, WebP and BMP**, and is held
|
|
50
|
+
to PHP's `getimagesizefromstring` by a cross-runtime test. That is wider than
|
|
51
|
+
the Node port, which reads PNG/GIF/JPEG only — so a WebP with `fit: cover`
|
|
52
|
+
gets a real centre-crop here and in PHP, and a stretched fill in Node.
|
|
53
|
+
|
|
54
|
+
- **The `imageRelIds` fixture**, pinning a defect rather than hiding it. Image
|
|
55
|
+
relationship ids come from a global media counter while a slide's own rels
|
|
56
|
+
start at `rId1`, so a single image on a single slide emits two
|
|
57
|
+
`<Relationship Id="rId1">` entries — a malformed OPC package that every
|
|
58
|
+
engine has always written. It is reproduced here deliberately: fixing one
|
|
59
|
+
engine alone would break part-level parity, so the fix has to land in all
|
|
60
|
+
three at once, and this fixture is what will prove it did.
|
|
61
|
+
|
|
62
|
+
### Security
|
|
63
|
+
|
|
64
|
+
- **`allow_http_images` defaults to `False`.** A document naming an `http(s)`
|
|
65
|
+
image does not cause a request; the caller opts in. A fixture asserts the
|
|
66
|
+
default emits a placeholder and fetches nothing.
|
|
67
|
+
- **The reader refuses a DOCTYPE before parsing.** A `.pptx` never legitimately
|
|
68
|
+
contains one, and the input is a file someone uploaded.
|
|
69
|
+
|
|
70
|
+
[Unreleased]: https://github.com/Particle-Academy/dark-slide-py/compare/v0.1.0...HEAD
|
|
71
|
+
[0.1.0]: https://github.com/Particle-Academy/dark-slide-py/releases/tag/v0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AGENTS.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|