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.
- fancy_last_word-0.1.0/.gitattributes +23 -0
- fancy_last_word-0.1.0/.github/workflows/ci.yml +79 -0
- fancy_last_word-0.1.0/.github/workflows/publish.yml +87 -0
- fancy_last_word-0.1.0/.gitignore +13 -0
- fancy_last_word-0.1.0/AGENTS.md +170 -0
- fancy_last_word-0.1.0/CHANGELOG.md +72 -0
- fancy_last_word-0.1.0/CLAUDE.md +1 -0
- fancy_last_word-0.1.0/LICENSE +21 -0
- fancy_last_word-0.1.0/PKG-INFO +206 -0
- fancy_last_word-0.1.0/README.md +162 -0
- fancy_last_word-0.1.0/pyproject.toml +52 -0
- fancy_last_word-0.1.0/scripts/php_tobytes.php +48 -0
- fancy_last_word-0.1.0/src/last_word/__init__.py +76 -0
- fancy_last_word-0.1.0/src/last_word/agent.py +256 -0
- fancy_last_word-0.1.0/src/last_word/exceptions.py +18 -0
- fancy_last_word-0.1.0/src/last_word/helpers/__init__.py +5 -0
- fancy_last_word-0.1.0/src/last_word/helpers/image_size.py +73 -0
- fancy_last_word-0.1.0/src/last_word/helpers/php.py +140 -0
- fancy_last_word-0.1.0/src/last_word/helpers/xml.py +43 -0
- fancy_last_word-0.1.0/src/last_word/markdown/__init__.py +6 -0
- fancy_last_word-0.1.0/src/last_word/markdown/from_markdown.py +412 -0
- fancy_last_word-0.1.0/src/last_word/markdown/to_markdown.py +229 -0
- fancy_last_word-0.1.0/src/last_word/py.typed +0 -0
- fancy_last_word-0.1.0/src/last_word/reader/__init__.py +5 -0
- fancy_last_word-0.1.0/src/last_word/reader/docx_reader.py +875 -0
- fancy_last_word-0.1.0/src/last_word/schema/__init__.py +7 -0
- fancy_last_word-0.1.0/src/last_word/schema/repairer.py +325 -0
- fancy_last_word-0.1.0/src/last_word/schema/schema.py +207 -0
- fancy_last_word-0.1.0/src/last_word/schema/types.py +156 -0
- fancy_last_word-0.1.0/src/last_word/schema/validator.py +256 -0
- fancy_last_word-0.1.0/src/last_word/writer/__init__.py +5 -0
- fancy_last_word-0.1.0/src/last_word/writer/docx_writer.py +840 -0
- fancy_last_word-0.1.0/tests/__init__.py +1 -0
- fancy_last_word-0.1.0/tests/_oracle.py +126 -0
- fancy_last_word-0.1.0/tests/conformance/__init__.py +1 -0
- fancy_last_word-0.1.0/tests/conformance/loader.py +214 -0
- fancy_last_word-0.1.0/tests/conformance/test_shared_suites.py +105 -0
- fancy_last_word-0.1.0/tests/conftest.py +39 -0
- fancy_last_word-0.1.0/tests/data/canonical.json +125 -0
- fancy_last_word-0.1.0/tests/data/canonical.md +35 -0
- fancy_last_word-0.1.0/tests/data/node-canonical.docx +0 -0
- fancy_last_word-0.1.0/tests/data/node-canonical.json +103 -0
- fancy_last_word-0.1.0/tests/fixtures.py +533 -0
- fancy_last_word-0.1.0/tests/test_agent.py +68 -0
- fancy_last_word-0.1.0/tests/test_cross_read.py +85 -0
- fancy_last_word-0.1.0/tests/test_describe.py +86 -0
- fancy_last_word-0.1.0/tests/test_determinism.py +24 -0
- fancy_last_word-0.1.0/tests/test_images.py +116 -0
- fancy_last_word-0.1.0/tests/test_markdown.py +159 -0
- fancy_last_word-0.1.0/tests/test_package_validity.py +159 -0
- fancy_last_word-0.1.0/tests/test_parity_extra.py +106 -0
- fancy_last_word-0.1.0/tests/test_parity_php.py +85 -0
- fancy_last_word-0.1.0/tests/test_php_helpers.py +108 -0
- fancy_last_word-0.1.0/tests/test_reader_tolerance.py +183 -0
- fancy_last_word-0.1.0/tests/test_round_trip.py +130 -0
- fancy_last_word-0.1.0/tests/test_validator.py +165 -0
- 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,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
|
+
`'` → `'` (XML1, not HTML's `'`).
|
|
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.
|