fancy-last-word 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 (57) hide show
  1. fancy_last_word-0.1.0/.gitattributes +23 -0
  2. fancy_last_word-0.1.0/.github/workflows/ci.yml +79 -0
  3. fancy_last_word-0.1.0/.github/workflows/publish.yml +87 -0
  4. fancy_last_word-0.1.0/.gitignore +13 -0
  5. fancy_last_word-0.1.0/AGENTS.md +170 -0
  6. fancy_last_word-0.1.0/CHANGELOG.md +72 -0
  7. fancy_last_word-0.1.0/CLAUDE.md +1 -0
  8. fancy_last_word-0.1.0/LICENSE +21 -0
  9. fancy_last_word-0.1.0/PKG-INFO +206 -0
  10. fancy_last_word-0.1.0/README.md +162 -0
  11. fancy_last_word-0.1.0/pyproject.toml +52 -0
  12. fancy_last_word-0.1.0/scripts/php_tobytes.php +48 -0
  13. fancy_last_word-0.1.0/src/last_word/__init__.py +76 -0
  14. fancy_last_word-0.1.0/src/last_word/agent.py +256 -0
  15. fancy_last_word-0.1.0/src/last_word/exceptions.py +18 -0
  16. fancy_last_word-0.1.0/src/last_word/helpers/__init__.py +5 -0
  17. fancy_last_word-0.1.0/src/last_word/helpers/image_size.py +73 -0
  18. fancy_last_word-0.1.0/src/last_word/helpers/php.py +140 -0
  19. fancy_last_word-0.1.0/src/last_word/helpers/xml.py +43 -0
  20. fancy_last_word-0.1.0/src/last_word/markdown/__init__.py +6 -0
  21. fancy_last_word-0.1.0/src/last_word/markdown/from_markdown.py +412 -0
  22. fancy_last_word-0.1.0/src/last_word/markdown/to_markdown.py +229 -0
  23. fancy_last_word-0.1.0/src/last_word/py.typed +0 -0
  24. fancy_last_word-0.1.0/src/last_word/reader/__init__.py +5 -0
  25. fancy_last_word-0.1.0/src/last_word/reader/docx_reader.py +875 -0
  26. fancy_last_word-0.1.0/src/last_word/schema/__init__.py +7 -0
  27. fancy_last_word-0.1.0/src/last_word/schema/repairer.py +325 -0
  28. fancy_last_word-0.1.0/src/last_word/schema/schema.py +207 -0
  29. fancy_last_word-0.1.0/src/last_word/schema/types.py +156 -0
  30. fancy_last_word-0.1.0/src/last_word/schema/validator.py +256 -0
  31. fancy_last_word-0.1.0/src/last_word/writer/__init__.py +5 -0
  32. fancy_last_word-0.1.0/src/last_word/writer/docx_writer.py +840 -0
  33. fancy_last_word-0.1.0/tests/__init__.py +1 -0
  34. fancy_last_word-0.1.0/tests/_oracle.py +126 -0
  35. fancy_last_word-0.1.0/tests/conformance/__init__.py +1 -0
  36. fancy_last_word-0.1.0/tests/conformance/loader.py +214 -0
  37. fancy_last_word-0.1.0/tests/conformance/test_shared_suites.py +105 -0
  38. fancy_last_word-0.1.0/tests/conftest.py +39 -0
  39. fancy_last_word-0.1.0/tests/data/canonical.json +125 -0
  40. fancy_last_word-0.1.0/tests/data/canonical.md +35 -0
  41. fancy_last_word-0.1.0/tests/data/node-canonical.docx +0 -0
  42. fancy_last_word-0.1.0/tests/data/node-canonical.json +103 -0
  43. fancy_last_word-0.1.0/tests/fixtures.py +533 -0
  44. fancy_last_word-0.1.0/tests/test_agent.py +68 -0
  45. fancy_last_word-0.1.0/tests/test_cross_read.py +85 -0
  46. fancy_last_word-0.1.0/tests/test_describe.py +86 -0
  47. fancy_last_word-0.1.0/tests/test_determinism.py +24 -0
  48. fancy_last_word-0.1.0/tests/test_images.py +116 -0
  49. fancy_last_word-0.1.0/tests/test_markdown.py +159 -0
  50. fancy_last_word-0.1.0/tests/test_package_validity.py +159 -0
  51. fancy_last_word-0.1.0/tests/test_parity_extra.py +106 -0
  52. fancy_last_word-0.1.0/tests/test_parity_php.py +85 -0
  53. fancy_last_word-0.1.0/tests/test_php_helpers.py +108 -0
  54. fancy_last_word-0.1.0/tests/test_reader_tolerance.py +183 -0
  55. fancy_last_word-0.1.0/tests/test_round_trip.py +130 -0
  56. fancy_last_word-0.1.0/tests/test_validator.py +165 -0
  57. fancy_last_word-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 last-word
59
+ uses: actions/checkout@v4
60
+ with:
61
+ repository: Particle-Academy/last-word
62
+ path: .php-last-word
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
+ LAST_WORD_PHP_SRC: ${{ github.workspace }}/.php-last-word/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 — last-word-py
2
+
3
+ The Python mirror of PHP `particle-academy/last-word` and Node
4
+ `@particle-academy/last-word`: a zero-dependency `.docx` writer + reader over a
5
+ JSON document model, plus markdown bridges.
6
+
7
+ This file describes **this repository's code**. Process — releases, version
8
+ policy, backports, cross-repo conventions — lives in the envelope's `AGENTS.md`
9
+ and must not be copied here.
10
+
11
+ ## Layout
12
+
13
+ ```
14
+ src/last_word/__init__.py public façade re-exports
15
+ src/last_word/agent.py the Agent surface, as module-level functions
16
+ src/last_word/exceptions.py SchemaException
17
+ src/last_word/schema/schema.py Schema (VERSION, json_schema())
18
+ src/last_word/schema/validator.py
19
+ src/last_word/schema/repairer.py
20
+ src/last_word/schema/types.py TypedDicts — editor hints only
21
+ src/last_word/writer/docx_writer.py
22
+ src/last_word/reader/docx_reader.py
23
+ src/last_word/markdown/to_markdown.py
24
+ src/last_word/markdown/from_markdown.py
25
+ src/last_word/helpers/xml.py escaping + the XML declaration
26
+ src/last_word/helpers/image_size.py
27
+ src/last_word/helpers/php.py PHP semantics Python does not share
28
+ ```
29
+
30
+ The layout mirrors the two peers file-for-file on purpose: a reviewer moving
31
+ between the three should recognise where they are. If you add a module here that
32
+ has no counterpart there, say why in its docstring.
33
+
34
+ ## The one thing that decides everything: PHP parity
35
+
36
+ **`tests/test_parity_php.py` runs the PHP writer as a subprocess and requires
37
+ byte-identical OOXML parts.** That is the acceptance test for the writer, and it
38
+ is why several things in here look un-Pythonic. Before changing any byte the
39
+ writer emits, understand that you are changing the contract with two other
40
+ engines.
41
+
42
+ Run the suite with an interpreter it can actually spawn:
43
+
44
+ ```sh
45
+ PHP_BIN='C:/Users/.../php.exe' python -m pytest
46
+ ```
47
+
48
+ `pythonpath = ["src"]` is set, so a bare checkout with nothing but pytest runs
49
+ everything — no install step.
50
+
51
+ ### Never cast the deciding vote on an open ruling
52
+
53
+ `.ai/plans/polyglot/parity/documents.md` §1.2 holds a ruling table for ~40
54
+ places where the PHP and Node engines already disagree. **That reconciliation
55
+ has not happened**, and the shipped PHP engine is the only executable oracle, so
56
+ this port follows **PHP everywhere**, including where the ruling table has
57
+ already decided against it. Doing otherwise would turn a documented 2-way
58
+ disagreement into an undocumented 3-way one.
59
+
60
+ Every such site carries a `# RULING PENDING:` comment naming what the ruling
61
+ says. `grep -rn "RULING PENDING" src/` is the whole worklist for when U3 lands.
62
+ Do not "fix" one of them in isolation — the reconciliation is a coordinated
63
+ change across all three engines in one release train.
64
+
65
+ ## Invariants
66
+
67
+ **XML is built by string concatenation, never `ElementTree.tostring`.** Attribute
68
+ order, self-closing style, namespace prefixes and the escaped character set are
69
+ all part of the byte contract, and the serialiser reproduces none of them
70
+ faithfully. `helpers/xml.py` mirrors PHP's `Helpers\Xml` exactly:
71
+ `text()` escapes `& " < >` (ENT_COMPAT — **not** the apostrophe), `attr()` adds
72
+ `'` → `&apos;` (XML1, not HTML's `&#039;`).
73
+
74
+ **Never call the builtin `round()`.** Python's is banker's rounding, PHP's is
75
+ half away from zero — `round(0.5)` is `0` here and `1` there. Image extents and
76
+ the read-back `widthPx`/`heightPx` both round, so the builtin is a one-pixel
77
+ disagreement with the reference on exactly the values a fixture picks. Use
78
+ `helpers.php.php_round` / `php_int_round`. `intdiv` is plain truncation, which is
79
+ `//` for the positive values this code uses.
80
+
81
+ **The rest of `helpers/php.py` exists for the same reason.** `php_truthy` is
82
+ `!empty()` — the string `"0"` is empty in PHP and truthy in Python. `is_numeric`
83
+ rejects booleans and accepts `" 12 "` and `"1e5"`. `php_str` casts `True` to
84
+ `"1"` and `None` to `""`. Reach for them wherever the reference leans on a PHP
85
+ primitive; do not open-code the shim at the call site.
86
+
87
+ **The zip is deterministic and platform-independent.** Fixed part order, a fixed
88
+ `date_time=(1980, 1, 1, 0, 0, 0)`, and a pinned `create_system = 0` — without
89
+ that last one `ZipInfo` picks the host platform and the same document produces
90
+ different bytes on Windows and Linux. Nothing writes a temp file: the archive is
91
+ built in a `BytesIO`, unlike PHP, whose `ZipArchive` needs a real path.
92
+
93
+ **The reader refuses a DOCTYPE before parsing.** A `.docx` never legitimately
94
+ carries one, and it is the entry point for entity-expansion attacks. A part
95
+ carrying one is treated as unparsable rather than trusted to a parser flag.
96
+
97
+ **The reader's deep walks use explicit stacks.** `_parse_inline_container`,
98
+ `_block_children` and `_first_descendant_by_name` are all driven by
99
+ attacker-supplied nesting, and Python's ~1000-frame limit is hit far sooner than
100
+ the peers' would be. Block containers still recurse (much clearer) but carry
101
+ `MAX_CONTAINER_DEPTH`, so a hostile file degrades instead of raising
102
+ `RecursionError` — the reader's contract is that it never throws on strange XML.
103
+ That cap is Python-only; the peers have no equivalent.
104
+
105
+ **The Repairer copies before it edits.** PHP arrays are values and JS objects are
106
+ spread; Python dicts are references, so a repairer that edited in place would
107
+ silently rewrite the agent's own document. `test_round_trip.py` pins it.
108
+
109
+ **The document model is plain `dict`s.** `schema/types.py` is TypedDicts for
110
+ editors and type checkers only — nothing constructs, isinstance-checks or
111
+ coerces to them. Making the model a dataclass would move the validation gate
112
+ into a constructor and reject exactly the loose JSON `validate_and_repair()`
113
+ exists to fix.
114
+
115
+ **Markdown indexes by codepoint, and that is fine.** PHP indexes by byte, but
116
+ every delimiter the inline parser scans for is ASCII, and no ASCII byte occurs
117
+ inside a UTF-8 multi-byte sequence — the offsets agree wherever they are
118
+ compared. The regexes carry `re.ASCII` because their PHP originals have no `/u`
119
+ modifier; dropping it would quietly make a non-breaking space a valid heading
120
+ separator here and nowhere else. The table-cell split is a hand-written scanner
121
+ even though `re` has lookbehind, because that is the algorithm the Rust and Go
122
+ ports must implement without lookaround.
123
+
124
+ ## Testing
125
+
126
+ TDD, vectors first. The suite is pytest, and the vector numbering matches the
127
+ peers' (`.ai/knowledge/last-word-spec.md`, and `documents.md` §5.4 for 9–11):
128
+
129
+ | File | What it pins |
130
+ | --- | --- |
131
+ | `test_parity_php.py` | byte-identical parts vs PHP for the shared fixture table |
132
+ | `test_parity_extra.py` | the same, for the writer paths that table misses |
133
+ | `test_package_validity.py` | the container is a well-formed OPC package — the half parity cannot prove, since two engines emitting the same broken package still agree |
134
+ | `test_determinism.py` | two `to_bytes` calls are byte-equal |
135
+ | `test_round_trip.py` | vector 1 — canonical doc survives `to_bytes → read` |
136
+ | `test_markdown.py` | vectors 2–4 — fixpoint, byte identity, and through-docx |
137
+ | `test_validator.py` | vector 4 — errors and repairs, `path`/`message` verbatim |
138
+ | `test_reader_tolerance.py` | vector 5 — Word-authored files, plus the DOCTYPE and depth guards |
139
+ | `test_describe.py` | vector 6 |
140
+ | `test_images.py` | vector 7 — sniffing and the 6.5in cap, asserted on the MODEL |
141
+ | `test_cross_read.py` | a frozen Node-written `.docx` read back |
142
+ | `test_php_helpers.py` | the shims, `php_round` above all |
143
+ | `tests/conformance/` | the shared `fancy-conformance` fixture tables |
144
+
145
+ `tests/fixtures.py` owns `DOCS` (ported verbatim from the Node suite — do not
146
+ "improve" an entry), `EXTRA_DOCS` (this port's additions) and `normalize_doc`
147
+ (the PHP suite's `lwNormalizeDoc`). `tests/data/` holds the frozen fixtures,
148
+ copied from the PHP repo rather than read across a sibling checkout: a
149
+ cross-read vector that only runs when the neighbouring repo happens to be cloned
150
+ is a vector that stops running.
151
+
152
+ Assert image sizing on the **read-back model**, not on EMU — a rounding-order
153
+ difference then surfaces as a model diff instead of hiding inside an extent
154
+ nobody compares.
155
+
156
+ ## Known non-fixpoints
157
+
158
+ `md → docx → md` is **not** byte-identical when the document has a table header
159
+ row. The writer forces `bold` into each header-cell run's `rPr` rather than
160
+ leaving it to a style, because style-only bold does not survive a read-back into
161
+ the model — so the header returns emphasised and markdown renders
162
+ `| **Name** | **Value** |`. PHP does exactly the same for the same input. The
163
+ chain settles after one application, which is what `test_markdown.py` asserts.
164
+
165
+ ## No runtime dependencies. Permanently.
166
+
167
+ If you think you need one, you have found something worth reporting, not
168
+ installing. `python-docx`, `lxml` and every all-in-one office library are out —
169
+ they would own the document model, and the document model is the product. Test
170
+ dependencies are a different question and need no ceremony.
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog 1.1.0](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` the version
9
+ number cannot promise what semver promises; read this file before upgrading a
10
+ minor.
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.1.0]
15
+
16
+ ### Added
17
+
18
+ - First release: the Python mirror of PHP `particle-academy/last-word` and Node
19
+ `@particle-academy/last-word`. Same JSON document model, same `.docx` on
20
+ disk — a file written by any of the three opens in the other two.
21
+ - **Agent surface** as module-level functions: `validate`,
22
+ `validate_and_repair`, `to_bytes`, `write`, `read`, `from_bytes`,
23
+ `to_markdown`, `from_markdown`, `describe`, `json_schema`, `version`.
24
+ Reachable as `last_word.to_bytes(doc)` or `last_word.agent.to_bytes(doc)`.
25
+ `write()` is **synchronous** — Node's is async only because browsers have no
26
+ synchronous filesystem, and that constraint does not exist here.
27
+ - **DOCX writer** producing the minimal viable OOXML part set in a fixed order,
28
+ with the title in `docProps/core.xml` (`dc:title`) and code/quote metadata in
29
+ `lastword:code[:{lang}]` / `lastword:quote` content-control tags — the
30
+ cross-language slots both mirrors already use.
31
+ - **DOCX reader** that round-trips this writer's own output and tolerates
32
+ Word-authored files: `outlineLvl` headings, `Heading9` clamped to 6, named
33
+ `w:highlight` colours, hyperlinks through the rels part, `numPr` lists with
34
+ `ilvl` nesting, tables, images, page breaks. Unknown constructs degrade to
35
+ paragraphs; it does not throw on strange XML. Pre-0.2.0 legacy slots (a
36
+ `Title`-styled paragraph, a `LastWordCode_{lang}` bookmark) still read.
37
+ - **Markdown bridges** (`to_markdown` / `from_markdown`), hand-rolled over the
38
+ same GFM subset as the peers, including the `<!-- pagebreak -->` convention.
39
+ - **Validator + Repairer** returning the peers' `{path, message}` error list
40
+ verbatim. The repairer never mutates the document it is given — Python dicts
41
+ are references where PHP arrays are values, so this needed saying and pinning.
42
+ - **`TypedDict` definitions** in `last_word.schema.types` for editor and
43
+ type-checker support. The runtime model stays plain `dict`s on purpose: the
44
+ Validator is the gate, and a dataclass would reject exactly the loose agent
45
+ JSON `validate_and_repair()` exists to fix.
46
+ - **Cross-runtime parity suite.** `tests/test_parity_php.py` drives the PHP
47
+ writer as a subprocess and asserts **byte-identical OOXML parts** for every
48
+ fixture — the shared table ported from the Node suite, plus this port's own
49
+ additions for adjacent tables, ragged rows, per-list numbering, relationship
50
+ allocation, XML escaping, hard breaks, alignment, the four image-extent paths
51
+ and astral-plane text. `tests/test_cross_read.py` reads a frozen `.docx`
52
+ written by the Node engine. A missing PHP interpreter fails under `CI` rather
53
+ than skipping.
54
+ - **Deterministic output**: no timestamps in any XML part, fixed part order, a
55
+ fixed 1980-01-01 zip date and a pinned `create_system`, so the same document
56
+ yields the same bytes on any platform.
57
+ - Zero runtime dependencies, permanently — `zipfile` and
58
+ `xml.etree.ElementTree` only.
59
+
60
+ ### Security
61
+
62
+ - The reader **refuses a DOCTYPE before parsing** any part. A `.docx` never
63
+ legitimately contains one, and entity expansion on untrusted input is a real
64
+ hazard (billion laughs, external entity reads); the construct is rejected
65
+ outright rather than trusted to a parser flag.
66
+ - The reader's inline and descendant walks are iterative, and its block walk
67
+ carries a depth cap, so deeply nested hostile XML degrades instead of raising
68
+ `RecursionError`. Python's frame limit is reached long before either peer's
69
+ stack would be, so this is a Python-specific guard with no counterpart there.
70
+
71
+ [Unreleased]: https://github.com/Particle-Academy/last-word-py/compare/v0.1.0...HEAD
72
+ [0.1.0]: https://github.com/Particle-Academy/last-word-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.