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.
Files changed (56) hide show
  1. fancy_dark_slide-0.1.0/.gitattributes +23 -0
  2. fancy_dark_slide-0.1.0/.github/workflows/ci.yml +79 -0
  3. fancy_dark_slide-0.1.0/.github/workflows/publish.yml +87 -0
  4. fancy_dark_slide-0.1.0/.gitignore +13 -0
  5. fancy_dark_slide-0.1.0/AGENTS.md +170 -0
  6. fancy_dark_slide-0.1.0/CHANGELOG.md +71 -0
  7. fancy_dark_slide-0.1.0/CLAUDE.md +1 -0
  8. fancy_dark_slide-0.1.0/LICENSE +21 -0
  9. fancy_dark_slide-0.1.0/PKG-INFO +238 -0
  10. fancy_dark_slide-0.1.0/README.md +194 -0
  11. fancy_dark_slide-0.1.0/art/fancy-ui.svg +26 -0
  12. fancy_dark_slide-0.1.0/pyproject.toml +52 -0
  13. fancy_dark_slide-0.1.0/scripts/php_imagesize.php +41 -0
  14. fancy_dark_slide-0.1.0/scripts/php_read.php +44 -0
  15. fancy_dark_slide-0.1.0/scripts/php_tobytes.php +48 -0
  16. fancy_dark_slide-0.1.0/src/dark_slide/__init__.py +81 -0
  17. fancy_dark_slide-0.1.0/src/dark_slide/agent.py +163 -0
  18. fancy_dark_slide-0.1.0/src/dark_slide/exceptions.py +21 -0
  19. fancy_dark_slide-0.1.0/src/dark_slide/helpers/__init__.py +1 -0
  20. fancy_dark_slide-0.1.0/src/dark_slide/helpers/chart_translator.py +201 -0
  21. fancy_dark_slide-0.1.0/src/dark_slide/helpers/color.py +85 -0
  22. fancy_dark_slide-0.1.0/src/dark_slide/helpers/emu.py +73 -0
  23. fancy_dark_slide-0.1.0/src/dark_slide/helpers/markdown_inline.py +132 -0
  24. fancy_dark_slide-0.1.0/src/dark_slide/helpers/syntax_highlighter.py +258 -0
  25. fancy_dark_slide-0.1.0/src/dark_slide/helpers/xml.py +44 -0
  26. fancy_dark_slide-0.1.0/src/dark_slide/py.typed +0 -0
  27. fancy_dark_slide-0.1.0/src/dark_slide/reader/__init__.py +5 -0
  28. fancy_dark_slide-0.1.0/src/dark_slide/reader/pptx_reader.py +630 -0
  29. fancy_dark_slide-0.1.0/src/dark_slide/schema/__init__.py +7 -0
  30. fancy_dark_slide-0.1.0/src/dark_slide/schema/repairer.py +123 -0
  31. fancy_dark_slide-0.1.0/src/dark_slide/schema/schema.py +215 -0
  32. fancy_dark_slide-0.1.0/src/dark_slide/schema/types.py +187 -0
  33. fancy_dark_slide-0.1.0/src/dark_slide/schema/validator.py +219 -0
  34. fancy_dark_slide-0.1.0/src/dark_slide/util.py +206 -0
  35. fancy_dark_slide-0.1.0/src/dark_slide/writer/__init__.py +5 -0
  36. fancy_dark_slide-0.1.0/src/dark_slide/writer/pptx_writer.py +2344 -0
  37. fancy_dark_slide-0.1.0/tests/__init__.py +1 -0
  38. fancy_dark_slide-0.1.0/tests/_oracle.py +126 -0
  39. fancy_dark_slide-0.1.0/tests/conformance/__init__.py +1 -0
  40. fancy_dark_slide-0.1.0/tests/conformance/loader.py +214 -0
  41. fancy_dark_slide-0.1.0/tests/conformance/test_shared_suites.py +104 -0
  42. fancy_dark_slide-0.1.0/tests/conftest.py +39 -0
  43. fancy_dark_slide-0.1.0/tests/fixtures.py +693 -0
  44. fancy_dark_slide-0.1.0/tests/test_agent.py +436 -0
  45. fancy_dark_slide-0.1.0/tests/test_animation_timing.py +301 -0
  46. fancy_dark_slide-0.1.0/tests/test_determinism.py +24 -0
  47. fancy_dark_slide-0.1.0/tests/test_helpers.py +603 -0
  48. fancy_dark_slide-0.1.0/tests/test_hyperlinks.py +119 -0
  49. fancy_dark_slide-0.1.0/tests/test_image_size.py +145 -0
  50. fancy_dark_slide-0.1.0/tests/test_package_validity.py +132 -0
  51. fancy_dark_slide-0.1.0/tests/test_parity_php.py +101 -0
  52. fancy_dark_slide-0.1.0/tests/test_reader_parity_php.py +116 -0
  53. fancy_dark_slide-0.1.0/tests/test_relationship_id_collision.py +139 -0
  54. fancy_dark_slide-0.1.0/tests/test_roundtrip.py +270 -0
  55. fancy_dark_slide-0.1.0/tests/test_v04_features.py +424 -0
  56. 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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .venv/
10
+ venv/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
@@ -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.