diff --git a/.github/scripts/package-lock.json b/.github/scripts/package-lock.json
index 730f9ee9c8..2bb6b553c8 100644
--- a/.github/scripts/package-lock.json
+++ b/.github/scripts/package-lock.json
@@ -10,9 +10,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
- "version": "0.2.104",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.104.tgz",
- "integrity": "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw==",
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.114.tgz",
+ "integrity": "sha512-plJ+j17jew9tDMHir/90hXrwoB8cZ9GrIyG19zIJcFyQ8pVhRXjZRJCtF2ElfPoiwkxMmNu1Klqyui4xP4shPg==",
"license": "SEE LICENSE IN README.md",
"dependencies": {
"@anthropic-ai/sdk": "^0.81.0",
@@ -22,378 +22,189 @@
"node": ">=18.0.0"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "^0.34.2",
- "@img/sharp-darwin-x64": "^0.34.2",
- "@img/sharp-linux-arm": "^0.34.2",
- "@img/sharp-linux-arm64": "^0.34.2",
- "@img/sharp-linux-x64": "^0.34.2",
- "@img/sharp-linuxmusl-arm64": "^0.34.2",
- "@img/sharp-linuxmusl-x64": "^0.34.2",
- "@img/sharp-win32-arm64": "^0.34.2",
- "@img/sharp-win32-x64": "^0.34.2"
+ "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.114",
+ "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.114",
+ "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.114",
+ "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.114",
+ "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.114",
+ "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.114",
+ "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.114",
+ "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.114"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
- "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/sdk": {
- "version": "0.81.0",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
- "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
- "license": "MIT",
- "dependencies": {
- "json-schema-to-ts": "^3.1.1"
- },
- "bin": {
- "anthropic-ai-sdk": "bin/cli"
- },
- "peerDependencies": {
- "zod": "^3.25.0 || ^4.0.0"
- },
- "peerDependenciesMeta": {
- "zod": {
- "optional": true
- }
- }
- },
- "node_modules/@anthropic-ai/sdk": {
- "version": "0.39.0",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz",
- "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==",
- "license": "MIT",
- "dependencies": {
- "@types/node": "^18.11.18",
- "@types/node-fetch": "^2.6.4",
- "abort-controller": "^3.0.0",
- "agentkeepalive": "^4.2.1",
- "form-data-encoder": "1.7.2",
- "formdata-node": "^4.3.2",
- "node-fetch": "^2.6.7"
- }
- },
- "node_modules/@babel/runtime": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
- "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@hono/node-server": {
- "version": "1.19.11",
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
- "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
- "license": "MIT",
- "engines": {
- "node": ">=18.14.1"
- },
- "peerDependencies": {
- "hono": "^4"
- }
- },
- "node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.2.114.tgz",
+ "integrity": "sha512-0/6LWrNilWpmiX6Xrj5plsBmCrCdKGERgAlKUZQEJZplnfuweFAJu7WXZB4KBaUpGlPO91zB/yqDh6kp5aZFbA==",
"cpu": [
"arm64"
],
- "license": "Apache-2.0",
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
- }
+ ]
},
- "node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.2.114.tgz",
+ "integrity": "sha512-sOHxq1rEO/KZg2iEZILTPn62lMRRMPqtxKx41uGLi3xjVDrAej6Ury9dDZjYBKkK9n4kBylXV0Oom2CZ14dDYw==",
"cpu": [
"x64"
],
- "license": "Apache-2.0",
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
- }
+ ]
},
- "node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.2.114.tgz",
+ "integrity": "sha512-j/SfEoN6+fyEsp8EuPe+xKcGfsZtaBmdUUH+YSRk5H/lYgy38yNsDhdt+AJMQcdMKfHsiwZ3Y9Ajoe9G9wNwHQ==",
"cpu": [
"arm64"
],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
- "cpu": [
- "x64"
+ "libc": [
+ "glibc"
],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
- "cpu": [
- "arm"
- ],
- "license": "LGPL-3.0-or-later",
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
+ ]
},
- "node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.2.114.tgz",
+ "integrity": "sha512-Mhd7bumTwWvkgjSJnYvCgyt8DfmLiUoK92mfvAKxHX7i5YSw+h5Kprqh2Cap+2SBbpwZvnwIoEYGCxhGwE5ddg==",
"cpu": [
"arm64"
],
- "license": "LGPL-3.0-or-later",
+ "libc": [
+ "musl"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
+ ]
},
- "node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.2.114.tgz",
+ "integrity": "sha512-wbaExKDleLlm2zHEhb74GKMLVhtO0IUmFhdimQcdL6CdTkmDE8ZJi53tYWE9+jq+XWNRXoM2yEmKPzXoUmsJng==",
"cpu": [
"x64"
],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
+ "libc": [
+ "glibc"
],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
+ ]
},
- "node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.2.114.tgz",
+ "integrity": "sha512-c1URsameGHAcghen+mY6jvr2oypiAPHXJIdP4huxR25zPdXWv2x+BCy+vcRVeajsq4VmFzAyQJwaM+BXkmXjAw==",
"cpu": [
"x64"
],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
- "cpu": [
- "arm"
+ "libc": [
+ "musl"
],
- "license": "Apache-2.0",
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
- }
+ ]
},
- "node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.2.114.tgz",
+ "integrity": "sha512-qeWdUpQymcKCA92osPmffG4QogrOSvuffPvm6c2OlMDjCPYs8vKG7bSe1Vq5tP9tfBszKPVJWBDh+2ANkNissQ==",
"cpu": [
"arm64"
],
- "license": "Apache-2.0",
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
- }
+ "win32"
+ ]
},
- "node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
+ "version": "0.2.114",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.2.114.tgz",
+ "integrity": "sha512-nVr43WwsKvWA6rojw15qBS/f31srukdLxy1KwKzpftlpmkzQ9Lh8uhIafOmoIPzz67f8VJ8JqHE0caA5YrhX9A==",
"cpu": [
"x64"
],
- "license": "Apache-2.0",
+ "license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
- }
+ "win32"
+ ]
},
- "node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/sdk": {
+ "version": "0.81.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
+ "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
+ "license": "MIT",
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1"
},
- "funding": {
- "url": "https://opencollective.com/libvips"
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
},
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
}
},
- "node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.39.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz",
+ "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^18.11.18",
+ "@types/node-fetch": "^2.6.4",
+ "abort-controller": "^3.0.0",
+ "agentkeepalive": "^4.2.1",
+ "form-data-encoder": "1.7.2",
+ "formdata-node": "^4.3.2",
+ "node-fetch": "^2.6.7"
}
},
- "node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
+ "node": ">=6.9.0"
}
},
- "node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
+ "node_modules/@hono/node-server": {
+ "version": "1.19.11",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
+ "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "license": "MIT",
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=18.14.1"
},
- "funding": {
- "url": "https://opencollective.com/libvips"
+ "peerDependencies": {
+ "hono": "^4"
}
},
"node_modules/@modelcontextprotocol/sdk": {
diff --git a/README.md b/README.md
index b49e7d46c0..77717b8d6b 100644
--- a/README.md
+++ b/README.md
@@ -165,6 +165,9 @@ Special thanks to these community members who have contributed code to SuperDoc:
+
+
+
Want to see your avatar here? Check the [Contributing Guide](CONTRIBUTING.md) to get started.
diff --git a/apps/docs/core/superdoc/configuration.mdx b/apps/docs/core/superdoc/configuration.mdx
index 6433165c19..855e0bf2bb 100644
--- a/apps/docs/core/superdoc/configuration.mdx
+++ b/apps/docs/core/superdoc/configuration.mdx
@@ -112,7 +112,7 @@ new SuperDoc({
- `viewing` - Read-only display
- `suggesting` - Track changes enabled
- See the [Track Changes extension](/extensions/track-changes) for accept/reject commands, and the [runnable example](https://github.com/superdoc-dev/superdoc/tree/main/examples/features/track-changes) for a complete workflow.
+ See the [Track Changes module](/modules/track-changes) for accept/reject commands, the Document API, and configuration. The [runnable example](https://github.com/superdoc-dev/superdoc/tree/main/examples/features/track-changes) shows a complete workflow.
@@ -124,11 +124,11 @@ new SuperDoc({
-
- Viewing-mode visibility controls for tracked changes
+
+ **Deprecated** — Use [`modules.trackChanges`](#track-changes-module) instead. This top-level key remains supported as an alias and will emit a one-time console warning.
- Show tracked-change markup and threads when `documentMode` is `viewing`
+ Show tracked-change markup and threads when `documentMode` is `viewing`.
@@ -196,6 +196,56 @@ new SuperDoc({
+### Track changes module
+
+
+ Track changes configuration. Supersedes the top-level `trackChanges` and `layoutEngineOptions.trackedChanges` keys, which remain supported as deprecated aliases.
+
+
+
+ Show tracked-change markup and threads when `documentMode` is `viewing`.
+
+
+ Rendering mode for tracked changes.
+ - `'review'`: show insertions and deletions inline (default for editing/suggesting).
+ - `'original'`: show the document as it existed before tracked changes (default for viewing when `visible` is `false`).
+ - `'final'`: show the document with changes applied.
+ - `'off'`: disable tracked-change rendering.
+
+
+ Whether the layout engine treats tracked changes as active.
+
+
+ How a tracked replacement (adjacent insertion + deletion created by typing over selected text) surfaces in the UI and API.
+ - `'paired'` (default, Google Docs model): the two halves share one id and resolve together with a single accept/reject click.
+ - `'independent'` (Microsoft Word / ECMA-376 §17.13.5 model): each insertion and each deletion has its own id, is addressable on its own, and resolves independently.
+
+
+
+
+```javascript
+new SuperDoc({
+ selector: '#editor',
+ document: 'contract.docx',
+ documentMode: 'viewing',
+ modules: {
+ trackChanges: { visible: true, mode: 'review' },
+ },
+});
+```
+
+Opt into Microsoft Word / ECMA-376-style independent revisions, where each insertion and each deletion has its own id and resolves on its own:
+
+```javascript
+new SuperDoc({
+ selector: '#editor',
+ document: 'contract.docx',
+ modules: {
+ trackChanges: { replacements: 'independent' },
+ },
+});
+```
+
### Toolbar module
diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 8c9d8df79f..d7ac1c5269 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -154,6 +154,7 @@
]
},
"modules/comments",
+ "modules/track-changes",
{
"group": "Toolbar",
"tag": "NEW",
@@ -455,6 +456,10 @@
"source": "/extensions/slash-menu",
"destination": "/extensions/context-menu"
},
+ {
+ "source": "/extensions/track-changes",
+ "destination": "/modules/track-changes"
+ },
{
"source": "/guides/breaking-changes-v1",
"destination": "/guides/migration/breaking-changes-v1"
diff --git a/apps/docs/extensions/overview.mdx b/apps/docs/extensions/overview.mdx
index 76eb84d7c9..584d03ecb2 100644
--- a/apps/docs/extensions/overview.mdx
+++ b/apps/docs/extensions/overview.mdx
@@ -53,7 +53,7 @@ Basic document capabilities:
### Advanced features
Complex functionality:
-- **[Track Changes](/extensions/track-changes)** - Revision tracking
+- **[Track Changes](/modules/track-changes)** - Revision tracking
- **[Comments](/extensions/comments)** - Discussions
- **[Field Annotation](/extensions/field-annotation)** - Form fields
- **[Document Section](/extensions/document-section)** - Locked sections
diff --git a/apps/docs/extensions/track-changes.mdx b/apps/docs/extensions/track-changes.mdx
deleted file mode 100644
index f86de2f950..0000000000
--- a/apps/docs/extensions/track-changes.mdx
+++ /dev/null
@@ -1,301 +0,0 @@
----
-hidden: true
-title: Track Changes extension
-sidebarTitle: Track Changes
----
-
-Track Changes records all edits with author attribution and timestamps, matching Microsoft Word's revision tracking.
-
-## Usage
-
-Enable through document mode:
-
-```javascript
-superdoc.setDocumentMode('suggesting'); // Enable tracking
-superdoc.setDocumentMode('editing'); // Disable tracking
-```
-
-Or toggle programmatically:
-
-
-```javascript Usage
-editor.commands.enableTrackChanges()
-editor.commands.disableTrackChanges()
-editor.commands.toggleTrackChanges()
-```
-
-```javascript Full Example
-import { SuperDoc } from 'superdoc';
-import 'superdoc/style.css';
-
-const superdoc = new SuperDoc({
- selector: '#editor',
- document: yourFile,
- onReady: (superdoc) => {
- const editor = superdoc.activeEditor;
- editor.commands.enableTrackChanges()
- editor.commands.disableTrackChanges()
- editor.commands.toggleTrackChanges()
- },
-});
-```
-
-
-## Commands
-
-### Accept changes
-
-
-```javascript Usage
-// Accept at current selection
-editor.commands.acceptTrackedChangeBySelection()
-
-// Accept a specific change by ID
-editor.commands.acceptTrackedChangeById('change-123')
-
-// Accept a change object (with start/end positions)
-editor.commands.acceptTrackedChange({ trackedChange: { start: 10, end: 50 } })
-
-// Accept changes in a range
-editor.commands.acceptTrackedChangesBetween(10, 50)
-
-// Accept all changes in the document
-editor.commands.acceptAllTrackedChanges()
-
-// Toolbar-aware accept (uses active thread or selection)
-editor.commands.acceptTrackedChangeFromToolbar()
-```
-
-```javascript Full Example
-import { SuperDoc } from 'superdoc';
-import 'superdoc/style.css';
-
-const superdoc = new SuperDoc({
- selector: '#editor',
- document: yourFile,
- onReady: (superdoc) => {
- const editor = superdoc.activeEditor;
- // Accept at current selection
- editor.commands.acceptTrackedChangeBySelection()
-
- // Accept a specific change by ID
- editor.commands.acceptTrackedChangeById('change-123')
-
- // Accept a change object (with start/end positions)
- editor.commands.acceptTrackedChange({ trackedChange: { start: 10, end: 50 } })
-
- // Accept changes in a range
- editor.commands.acceptTrackedChangesBetween(10, 50)
-
- // Accept all changes in the document
- editor.commands.acceptAllTrackedChanges()
-
- // Toolbar-aware accept (uses active thread or selection)
- editor.commands.acceptTrackedChangeFromToolbar()
- },
-});
-```
-
-
-### Reject changes
-
-
-```javascript Usage
-// Reject at current selection
-editor.commands.rejectTrackedChangeOnSelection()
-
-// Reject a specific change by ID
-editor.commands.rejectTrackedChangeById('change-123')
-
-// Reject a change object
-editor.commands.rejectTrackedChange({ trackedChange: { start: 10, end: 50 } })
-
-// Reject changes in a range
-editor.commands.rejectTrackedChangesBetween(10, 50)
-
-// Reject all changes in the document
-editor.commands.rejectAllTrackedChanges()
-
-// Toolbar-aware reject
-editor.commands.rejectTrackedChangeFromToolbar()
-```
-
-```javascript Full Example
-import { SuperDoc } from 'superdoc';
-import 'superdoc/style.css';
-
-const superdoc = new SuperDoc({
- selector: '#editor',
- document: yourFile,
- onReady: (superdoc) => {
- const editor = superdoc.activeEditor;
- // Reject at current selection
- editor.commands.rejectTrackedChangeOnSelection()
-
- // Reject a specific change by ID
- editor.commands.rejectTrackedChangeById('change-123')
-
- // Reject a change object
- editor.commands.rejectTrackedChange({ trackedChange: { start: 10, end: 50 } })
-
- // Reject changes in a range
- editor.commands.rejectTrackedChangesBetween(10, 50)
-
- // Reject all changes in the document
- editor.commands.rejectAllTrackedChanges()
-
- // Toolbar-aware reject
- editor.commands.rejectTrackedChangeFromToolbar()
- },
-});
-```
-
-
-### Insert tracked change programmatically
-
-Use `insertTrackedChange` to add tracked edits from external sources (e.g., AI suggestions):
-
-
-```javascript Usage
-editor.commands.insertTrackedChange({
- from: 10,
- to: 25,
- text: 'replacement text',
- comment: 'AI suggestion: improved wording'
-})
-```
-
-```javascript Full Example
-import { SuperDoc } from 'superdoc';
-import 'superdoc/style.css';
-
-const superdoc = new SuperDoc({
- selector: '#editor',
- document: yourFile,
- onReady: (superdoc) => {
- const editor = superdoc.activeEditor;
- editor.commands.insertTrackedChange({
- from: 10,
- to: 25,
- text: 'replacement text',
- comment: 'AI suggestion: improved wording'
- })
- },
-});
-```
-
-
-**Parameters:**
-
-
- Object with `from`, `to`, `text`, `user`, `comment`, `addToHistory`, `emitCommentEvent`
-
-
-### View modes
-
-
-```javascript Usage
-// Show document as it was before changes
-editor.commands.toggleTrackChangesShowOriginal()
-editor.commands.enableTrackChangesShowOriginal()
-editor.commands.disableTrackChangesShowOriginal()
-
-// Show document as if all changes were accepted
-editor.commands.toggleTrackChangesShowFinal()
-editor.commands.enableTrackChangesShowFinal()
-```
-
-```javascript Full Example
-import { SuperDoc } from 'superdoc';
-import 'superdoc/style.css';
-
-const superdoc = new SuperDoc({
- selector: '#editor',
- document: yourFile,
- onReady: (superdoc) => {
- const editor = superdoc.activeEditor;
- // Show document as it was before changes
- editor.commands.toggleTrackChangesShowOriginal()
- editor.commands.enableTrackChangesShowOriginal()
- editor.commands.disableTrackChangesShowOriginal()
-
- // Show document as if all changes were accepted
- editor.commands.toggleTrackChangesShowFinal()
- editor.commands.enableTrackChangesShowFinal()
- },
-});
-```
-
-
-## Helpers
-
-```javascript
-import { trackChangesHelpers } from 'superdoc';
-
-// Get all tracked changes in the document
-const changes = trackChangesHelpers.getTrackChanges(editor.state);
-// Returns: [{ mark, from, to }, ...]
-
-// Get a specific change by ID
-const change = trackChangesHelpers.getTrackChanges(editor.state, 'change-123');
-```
-
-## Change types
-
-| Type | Mark | Visual |
-|------|------|--------|
-| Insertion | `trackInsert` | Green underline |
-| Deletion | `trackDelete` | Red strikethrough |
-| Format change | `trackFormat` | Records before/after formatting |
-
-Each change includes author name, email, timestamp, and a unique ID.
-
-## Export behavior
-
-Changes export to DOCX as Word revisions:
-
-
-```javascript Usage
-// Export with changes preserved
-await superdoc.export();
-
-// Accept all first, then export clean
-editor.commands.acceptAllTrackedChanges();
-await superdoc.export();
-```
-
-```javascript Full Example
-import { SuperDoc } from 'superdoc';
-import 'superdoc/style.css';
-
-const superdoc = new SuperDoc({
- selector: '#editor',
- document: yourFile,
- onReady: (superdoc) => {
- const editor = superdoc.activeEditor;
- // Export with changes preserved
- await superdoc.export();
-
- // Accept all first, then export clean
- editor.commands.acceptAllTrackedChanges();
- await superdoc.export();
- },
-});
-```
-
-
-## Full example
-
-
- Runnable example with mode switching, accept/reject, and comments sidebar
-
-
-## Source code
-
-import { SourceCodeLink } from '/snippets/components/source-code-link.jsx'
-
-
diff --git a/apps/docs/getting-started/frameworks/angular.mdx b/apps/docs/getting-started/frameworks/angular.mdx
index 9facafb7ee..3395a0e6e7 100644
--- a/apps/docs/getting-started/frameworks/angular.mdx
+++ b/apps/docs/getting-started/frameworks/angular.mdx
@@ -6,6 +6,8 @@ keywords: "angular docx editor, angular word component, superdoc angular, angula
SuperDoc works with Angular through direct DOM manipulation. No wrapper needed.
+Requires Angular 17.2+. `viewChild()` and `input()` are stable from Angular 19; developer preview before that. On older versions, use `@ViewChild`, `@Input`, and `ngOnDestroy` in place of `inject(DestroyRef)`.
+
## Install
```bash
@@ -15,7 +17,7 @@ npm install superdoc
## Basic setup
```typescript
-import { Component, ElementRef, ViewChild, OnInit, OnDestroy } from '@angular/core';
+import { Component, ElementRef, viewChild, AfterViewInit, inject, DestroyRef } from '@angular/core';
import { SuperDoc } from 'superdoc';
import 'superdoc/style.css';
@@ -23,21 +25,21 @@ import 'superdoc/style.css';
selector: 'app-editor',
template: ``,
})
-export class EditorComponent implements OnInit, OnDestroy {
- @ViewChild('editor', { static: true }) editorRef!: ElementRef;
+export class EditorComponent implements AfterViewInit {
+ private readonly editorRef = viewChild.required>('editor');
private superdoc: SuperDoc | null = null;
- ngOnInit() {
+ constructor() {
+ inject(DestroyRef).onDestroy(() => this.superdoc?.destroy());
+ }
+
+ ngAfterViewInit() {
this.superdoc = new SuperDoc({
- selector: this.editorRef.nativeElement,
+ selector: this.editorRef().nativeElement,
document: 'contract.docx',
documentMode: 'editing',
});
}
-
- ngOnDestroy() {
- this.superdoc?.destroy();
- }
}
```
@@ -46,7 +48,7 @@ export class EditorComponent implements OnInit, OnDestroy {
A reusable editor component with mode switching and export:
```typescript
-import { Component, ElementRef, ViewChild, Input, OnInit, OnDestroy } from '@angular/core';
+import { Component, ElementRef, viewChild, input, AfterViewInit, inject, DestroyRef } from '@angular/core';
import { SuperDoc } from 'superdoc';
import 'superdoc/style.css';
@@ -62,27 +64,27 @@ import 'superdoc/style.css';
`,
})
-export class DocEditorComponent implements OnInit, OnDestroy {
- @ViewChild('editor', { static: true }) editorRef!: ElementRef;
- @Input() document!: File | string;
- @Input() user?: { name: string; email: string };
+export class DocEditorComponent implements AfterViewInit {
+ private readonly editorRef = viewChild.required>('editor');
+ readonly document = input.required();
+ readonly user = input<{ name: string; email: string }>();
private superdoc: SuperDoc | null = null;
- ngOnInit() {
+ constructor() {
+ inject(DestroyRef).onDestroy(() => this.superdoc?.destroy());
+ }
+
+ ngAfterViewInit() {
this.superdoc = new SuperDoc({
- selector: this.editorRef.nativeElement,
- document: this.document,
+ selector: this.editorRef().nativeElement,
+ document: this.document(),
documentMode: 'editing',
- user: this.user,
- onReady: () => console.log('Editor ready'),
+ user: this.user(),
+ onReady: () => console.log('Editor ready!'),
});
}
- ngOnDestroy() {
- this.superdoc?.destroy();
- }
-
setMode(mode: 'editing' | 'viewing' | 'suggesting') {
this.superdoc?.setDocumentMode(mode);
}
@@ -96,6 +98,10 @@ export class DocEditorComponent implements OnInit, OnDestroy {
## Handle file uploads
```typescript
+import { Component, ElementRef, viewChild, inject, DestroyRef } from '@angular/core';
+import { SuperDoc } from 'superdoc';
+import 'superdoc/style.css';
+
@Component({
selector: 'app-upload-editor',
template: `
@@ -103,25 +109,25 @@ export class DocEditorComponent implements OnInit, OnDestroy {
`,
})
-export class UploadEditorComponent implements OnDestroy {
- @ViewChild('editor', { static: true }) editorRef!: ElementRef;
+export class UploadEditorComponent {
+ private readonly editorRef = viewChild.required>('editor');
private superdoc: SuperDoc | null = null;
+ constructor() {
+ inject(DestroyRef).onDestroy(() => this.superdoc?.destroy());
+ }
+
onFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.superdoc?.destroy();
this.superdoc = new SuperDoc({
- selector: this.editorRef.nativeElement,
+ selector: this.editorRef().nativeElement,
document: file,
documentMode: 'editing',
});
}
-
- ngOnDestroy() {
- this.superdoc?.destroy();
- }
}
```
diff --git a/apps/docs/llms.txt b/apps/docs/llms.txt
index 0f0668d445..1e76a62b5b 100644
--- a/apps/docs/llms.txt
+++ b/apps/docs/llms.txt
@@ -9,6 +9,7 @@ SuperDoc renders, edits, and automates .docx files — in the browser, headless
- Reads and writes OOXML natively — documents stay real .docx files at every step
- 180+ MCP tools covering reading, editing, formatting, comments, tracked changes, and more
- Tracked changes and comments as first-class operations
+- Math equations render natively as MathML — no external library needed
- Same document model across browser editor, headless Node, and APIs
- Stateless API for convert, annotate, sign, and verify
- Real-time collaboration with Yjs CRDT
diff --git a/apps/docs/modules/comments.mdx b/apps/docs/modules/comments.mdx
index cf9b1f456d..8c6e0783a6 100644
--- a/apps/docs/modules/comments.mdx
+++ b/apps/docs/modules/comments.mdx
@@ -140,20 +140,27 @@ modules: {
## Viewing mode visibility
-Comments are hidden by default when `documentMode` is `viewing`. Use the
-top-level `comments.visible` and `trackChanges.visible` flags to control what
-renders in read-only mode.
+Comments are hidden by default when `documentMode` is `viewing`. Use
+`comments.visible` and `modules.trackChanges.visible` to control what renders
+in read-only mode.
```javascript
new SuperDoc({
selector: "#viewer",
document: "contract.docx",
documentMode: "viewing",
- comments: { visible: true }, // Standard comment threads
- trackChanges: { visible: false }, // Tracked-change markup + threads
+ comments: { visible: true }, // Standard comment threads
+ modules: {
+ trackChanges: { visible: false }, // Tracked-change markup + threads
+ },
});
```
+
+ The top-level `trackChanges` key still works as a deprecated alias for
+ `modules.trackChanges` and will emit a one-time console warning.
+
+
## Setting up the comments UI
During initialization:
diff --git a/apps/docs/modules/overview.mdx b/apps/docs/modules/overview.mdx
index d40a7de9a8..e2326b85e8 100644
--- a/apps/docs/modules/overview.mdx
+++ b/apps/docs/modules/overview.mdx
@@ -29,6 +29,9 @@ const superdoc = new SuperDoc({
Threaded discussions and annotations
+
+ Word-style revision tracking with accept/reject
+
Customizable formatting controls
@@ -53,9 +56,6 @@ Each module is configured via `modules.` in the [SuperDoc configuration](/
These features are configured at the top level rather than through `modules`, but are commonly used alongside modules.
-
- Accept/reject workflow with `documentMode: 'suggesting'`
-
Provider-based spell check on the layout-engine editor surface
diff --git a/apps/docs/modules/toolbar/built-in.mdx b/apps/docs/modules/toolbar/built-in.mdx
index b99b12b8ed..2485f9d7a5 100644
--- a/apps/docs/modules/toolbar/built-in.mdx
+++ b/apps/docs/modules/toolbar/built-in.mdx
@@ -328,21 +328,24 @@ The toolbar dropdown lists only what you register in `fonts`. Imported documents
- Display name shown in the dropdown
+ Display name shown in the dropdown, and the value applied to the selected text. Must match the first family name in `key` — otherwise the dropdown won't light up the current font when the cursor is inside a run that uses it.
- Font-family CSS value applied to text
+ Stable identity for the option. Also used as the row's preview `font-family` so each row renders in its own typeface. Typically a full CSS stack (e.g. `'Cambria, serif'`).
Font weight
+
+ Optional. Overrides per-row rendering. Use `props.style.fontFamily` to preview the row in a different font than `key`.
+
```javascript
fonts: [
- { label: 'Arial', key: 'Arial' },
- { label: 'Times', key: 'Times New Roman' },
+ { label: 'Arial', key: 'Arial, sans-serif' },
+ { label: 'Times New Roman', key: 'Times New Roman, serif' },
{ label: 'Brand Font', key: 'BrandFont, sans-serif', fontWeight: 400 }
]
```
diff --git a/apps/docs/modules/track-changes.mdx b/apps/docs/modules/track-changes.mdx
new file mode 100644
index 0000000000..d61a2b8c43
--- /dev/null
+++ b/apps/docs/modules/track-changes.mdx
@@ -0,0 +1,443 @@
+---
+title: Track Changes
+keywords: "word track changes, document revisions, accept reject edits, docx tracked changes, review workflow, suggesting mode"
+---
+
+Word-style revision tracking. Every edit carries an author, a timestamp, and an id. Accept or reject one at a time, by selection, or in bulk. Changes round-trip through DOCX as native Word revisions — import, edit, export, nothing lost.
+
+## Quick start
+
+```javascript
+const superdoc = new SuperDoc({
+ selector: "#editor",
+ document: "contract.docx",
+ documentMode: "suggesting", // record new edits as tracked changes
+ user: {
+ name: "John Smith",
+ email: "john@company.com",
+ },
+ modules: {
+ trackChanges: {
+ visible: true,
+ },
+ },
+ onCommentsUpdate: (payload) => {
+ if (payload.type === "trackedChange") {
+ console.log(payload.event, payload.trackedChangeType, payload.changeId);
+ }
+ },
+});
+```
+
+
+ Tracked-edit recording follows `documentMode`. Set `documentMode: 'suggesting'` (or call `superdoc.setDocumentMode('suggesting')` later) to start recording new edits as revisions.
+
+
+## Configuration
+
+
+ Show tracked-change markup when `documentMode` is `viewing`.
+
+
+
+ Rendering mode.
+
+
+ - `'review'` — show insertions and deletions inline (default for editing and suggesting).
+ - `'original'` — show the document as it was before tracked changes (default for viewing when `visible` is `false`).
+ - `'final'` — show the document with all changes applied.
+ - `'off'` — suppress tracked-change rendering entirely.
+
+
+
+
+ Whether the layout engine treats tracked changes as active. Turn off to render the document without any revision UI.
+
+
+
+ How a tracked replacement (typing over selected text) surfaces in the API and UI. See [Revision model](#revision-model).
+
+
+ - `'paired'` (default, Google Docs model) — the insertion and deletion share one id and resolve together.
+ - `'independent'` (Microsoft Word / ECMA-376 §17.13.5) — each side has its own id and resolves on its own.
+
+
+
+## Viewing mode visibility
+
+Tracked-change markup is hidden by default when `documentMode` is `'viewing'`. Flip `modules.trackChanges.visible` to show it in read-only mode.
+
+```javascript
+new SuperDoc({
+ selector: "#viewer",
+ document: "contract.docx",
+ documentMode: "viewing",
+ modules: {
+ trackChanges: { visible: true },
+ },
+});
+```
+
+
+ The top-level `trackChanges` key still works as a deprecated alias for `modules.trackChanges` and prints a one-time console warning.
+
+
+## Revision model
+
+SuperDoc supports two models for how a tracked replacement (an insertion paired with a deletion, created when a user types over selected text) shows up in the API and UI. Pick the one that matches the editor your users expect.
+
+| Model | `modules.trackChanges.replacements` | Behavior |
+| --- | --- | --- |
+| **Paired** (default — Google Docs) | `'paired'` | Both halves share one id. One accept/reject resolves both. One sidebar row per replacement. |
+| **Independent** (Microsoft Word / ECMA-376 §17.13.5) | `'independent'` | Each insertion and each deletion has its own id. Accept/reject resolves one side at a time. A replacement produces two sidebar rows. |
+
+Both modes round-trip cleanly through DOCX — the OOXML always emits one `` / `` per mark. The difference is how the API surfaces the revisions at runtime.
+
+### Paired (default)
+
+```javascript
+const superdoc = new SuperDoc({
+ selector: "#editor",
+ document: yourFile,
+ // default: modules.trackChanges.replacements === 'paired'
+});
+```
+
+### Independent (Word-style)
+
+```javascript
+const superdoc = new SuperDoc({
+ selector: "#editor",
+ document: yourFile,
+ modules: {
+ trackChanges: { replacements: "independent" },
+ },
+});
+```
+
+With `replacements: 'independent'`, `editor.doc.trackChanges.list()` returns one entry per revision and `decide({ id })` resolves exactly that one side. The other half of the replacement stays in the document, still addressable by its own id — useful when you're building a custom sidebar and want each revision as a separate row.
+
+## Document API
+
+Use the Document API to list, read, and resolve tracked changes. It's stable, typed, framework-agnostic, and works the same in the visual editor and headless mode.
+
+```javascript
+const editor = superdoc.activeEditor;
+
+// List every tracked change. In 'paired' mode a replacement is one entry;
+// in 'independent' mode it's two (one insert, one delete).
+const result = editor.doc.trackChanges.list();
+
+for (const item of result.items) {
+ console.log(item.id, item.type, item.author, item.excerpt);
+}
+
+// Fetch a single change by id.
+const change = editor.doc.trackChanges.get({ id: result.items[0].id });
+
+// Accept or reject by id.
+editor.doc.trackChanges.decide({ decision: "accept", target: { id: change.id } });
+
+// Accept or reject everything in the document.
+editor.doc.trackChanges.decide({ decision: "accept", target: { scope: "all" } });
+```
+
+Every entry returned by `list()` and `get()` has this shape:
+
+
+
+
+ SuperDoc's internal id for the revision. Stable across calls.
+
+
+ Entity address — `{ kind: 'entity', entityType: 'trackedChange', entityId: id }`.
+
+
+ Revision kind. In `'paired'` mode a replacement reports as `'insert'` (both halves grouped). In `'independent'` mode the insertion and deletion are separate entries.
+
+
+ Display name of the reviewer.
+
+
+ Email of the reviewer.
+
+
+ Reviewer avatar URL, when provided.
+
+
+ ISO timestamp of the revision.
+
+
+ The affected text.
+
+
+ Original Word `w:id` values from the source DOCX, keyed by `insert` / `delete` / `format`. Useful for correlating SuperDoc revisions with upstream systems.
+
+
+
+
+`list()` accepts an optional query with `limit`, `offset`, and `type` (`'insert' | 'delete' | 'format'`) for pagination and filtering. See the full reference:
+
+- [`trackChanges.list`](/document-api/reference/track-changes/list)
+- [`trackChanges.get`](/document-api/reference/track-changes/get)
+- [`trackChanges.decide`](/document-api/reference/track-changes/decide)
+
+## Toggling tracked edits
+
+Control recording via document mode:
+
+```javascript
+superdoc.setDocumentMode("suggesting"); // record new edits as tracked changes
+superdoc.setDocumentMode("editing"); // stop recording
+superdoc.setDocumentMode("viewing"); // read-only
+```
+
+## Change types
+
+| Type | Mark | Visual |
+| --- | --- | --- |
+| Insertion | `trackInsert` | Underlined in the reviewer's color |
+| Deletion | `trackDelete` | Strikethrough in the reviewer's color |
+| Format change | `trackFormat` | Records the before/after formatting on the run |
+
+Each mark carries `id`, `author`, `authorEmail`, `date`, and — for imports from Word — the original `w:id` as `sourceId` so you can round-trip revision provenance.
+
+## Events
+
+Tracked-change events are delivered through the same `onCommentsUpdate` callback as comment events. The top-level `type` field tells them apart; filter on `type === 'trackedChange'` and read the flat payload.
+
+```javascript
+onCommentsUpdate: (payload) => {
+ if (payload.type !== "trackedChange") return;
+
+ switch (payload.event) {
+ case "add":
+ // New tracked change created.
+ break;
+ case "update":
+ // Existing tracked change updated (e.g. reply added, author edited).
+ break;
+ case "change-accepted":
+ await markAccepted(payload.changeId);
+ break;
+ case "change-rejected":
+ await markRejected(payload.changeId);
+ break;
+ case "resolved":
+ // Tracked-change comment thread resolved.
+ break;
+ }
+};
+```
+
+### Payload fields
+
+
+
+
+ Always `'trackedChange'` for tracked-change events. Comment events use other values.
+
+
+ Event kind: `'add'`, `'update'`, `'deleted'`, `'resolved'`, `'selected'`, `'change-accepted'`, or `'change-rejected'`.
+
+
+ The tracked-change id (matches `TrackChangeInfo.id` returned by `trackChanges.list()`).
+
+
+ The mark type. `'both'` is used for a paired replacement (insertion and deletion together).
+
+
+ The inserted or affected text.
+
+
+ The deleted text, for deletions and replacements.
+
+
+ A human-facing summary derived from the change (e.g. `'hyperlinkAdded'`, `'formatChanged'`).
+
+
+ Display name.
+
+
+ Email.
+
+
+ Avatar URL, when provided.
+
+
+ ISO timestamp.
+
+
+ Source document id.
+
+
+ Original author info from the imported DOCX, when available — `{ name }`.
+
+
+
+
+
+ Events fire once per user action, not once per mark. A tracked replacement in paired mode emits one event with `trackedChangeType: 'both'`. To enumerate the current set of revisions, use `editor.doc.trackChanges.list()` — not the event stream.
+
+
+## Permissions
+
+Accept and reject permissions are governed by the same `permissionResolver` used for comments. Return `false` from the resolver to block an action.
+
+```javascript
+modules: {
+ comments: {
+ permissionResolver: ({ permission, trackedChange, currentUser, defaultDecision }) => {
+ if (
+ permission === "REJECT_OTHER" &&
+ trackedChange?.attrs?.authorEmail !== currentUser?.email
+ ) {
+ return false;
+ }
+ return defaultDecision;
+ },
+ },
+}
+```
+
+Tracked-change permission types:
+
+| Permission | Description |
+| --- | --- |
+| `RESOLVE_OWN` | Accept your own tracked changes |
+| `RESOLVE_OTHER` | Accept other users' tracked changes |
+| `REJECT_OWN` | Reject your own tracked changes |
+| `REJECT_OTHER` | Reject other users' tracked changes |
+
+See [Comments → Permission resolver](/modules/comments#permission-resolver) for the full list of permission types and resolver behavior.
+
+## Word import/export
+
+Tracked changes round-trip through DOCX as native Word revisions. Import it. Edit it. Export it. Nothing lost.
+
+```javascript
+// Export with revisions preserved. Word opens the file and shows the
+// insertions and deletions under Review.
+const blob = await superdoc.export();
+
+// Accept all first, then export a clean copy.
+superdoc.activeEditor.doc.trackChanges.decide({
+ decision: "accept",
+ target: { scope: "all" },
+});
+const cleanBlob = await superdoc.export();
+```
+
+Imported Word revisions preserve their original `w:id` values as `wordRevisionIds` on each `TrackChangeInfo` entry, so you can correlate SuperDoc revisions with the source document or an external review system.
+
+
+ Round-trip support today covers inserted run content (``), deleted run content (``), and run-level format changes (``). Paragraph-level property changes, tracked table row and cell edits, and tracked moves are on the roadmap — they import as accepted content today.
+
+
+## Legacy editor commands
+
+
+ **Deprecated**. Use the [Document API](#document-api) (`editor.doc.trackChanges.list()`, `editor.doc.trackChanges.decide(...)`) instead. The commands below remain available but will be removed in a future release.
+
+
+These legacy commands live on `superdoc.activeEditor.commands` and predate the Document API. They're still used by the built-in toolbar and a handful of keyboard shortcuts.
+
+### Enable and toggle
+
+```javascript
+superdoc.activeEditor.commands.enableTrackChanges();
+superdoc.activeEditor.commands.disableTrackChanges();
+superdoc.activeEditor.commands.toggleTrackChanges();
+```
+
+### Accept
+
+```javascript
+superdoc.activeEditor.commands.acceptTrackedChangeBySelection();
+superdoc.activeEditor.commands.acceptTrackedChangeById("change-123");
+superdoc.activeEditor.commands.acceptTrackedChangesBetween(10, 50);
+superdoc.activeEditor.commands.acceptAllTrackedChanges();
+superdoc.activeEditor.commands.acceptTrackedChangeFromToolbar();
+```
+
+### Reject
+
+```javascript
+superdoc.activeEditor.commands.rejectTrackedChangeOnSelection();
+superdoc.activeEditor.commands.rejectTrackedChangeById("change-123");
+superdoc.activeEditor.commands.rejectTrackedChangesBetween(10, 50);
+superdoc.activeEditor.commands.rejectAllTrackedChanges();
+superdoc.activeEditor.commands.rejectTrackedChangeFromToolbar();
+```
+
+### Insert a tracked change programmatically
+
+```javascript
+superdoc.activeEditor.commands.insertTrackedChange({
+ from: 10,
+ to: 25,
+ text: "replacement text",
+ comment: "AI suggestion: improved wording",
+});
+```
+
+
+
+
+ Start of the range.
+
+
+ End of the range. When `from === to`, the change is a pure insertion.
+
+
+ Replacement text. Omit or leave empty for a pure deletion.
+
+
+ Explicit id for the change. Defaults to a generated UUID.
+
+
+ Author override (`{ name, email, image? }`). Defaults to the editor's `user` option.
+
+
+ Optional comment to attach.
+
+
+ Record the transaction in the undo stack.
+
+
+ Emit a `trackedChange` event through `onCommentsUpdate`.
+
+
+
+
+### View modes
+
+Temporarily render the document without applying revisions — useful for previewing the accepted or original state:
+
+```javascript
+// Show the document as it was before tracked changes.
+superdoc.activeEditor.commands.enableTrackChangesShowOriginal();
+superdoc.activeEditor.commands.disableTrackChangesShowOriginal();
+superdoc.activeEditor.commands.toggleTrackChangesShowOriginal();
+
+// Show the document with all changes accepted.
+superdoc.activeEditor.commands.enableTrackChangesShowFinal();
+superdoc.activeEditor.commands.toggleTrackChangesShowFinal();
+```
+
+## Full example
+
+
+ Runnable example: mode switching, accept and reject, comments sidebar, DOCX import and export.
+
diff --git a/package.json b/package.json
index 7a7094bba6..499c44ab79 100644
--- a/package.json
+++ b/package.json
@@ -132,14 +132,14 @@
"canvas": "3.2.3",
"happy-dom": "20.4.0",
"jsdom": "27.3.0",
- "vue": "3.5.25",
- "@vue/compiler-core": "3.5.25",
- "@vue/compiler-dom": "3.5.25",
- "@vue/compiler-sfc": "3.5.25",
- "@vue/runtime-core": "3.5.25",
- "@vue/runtime-dom": "3.5.25",
- "@vue/server-renderer": "3.5.25",
- "@vue/shared": "3.5.25",
+ "vue": "3.5.32",
+ "@vue/compiler-core": "3.5.32",
+ "@vue/compiler-dom": "3.5.32",
+ "@vue/compiler-sfc": "3.5.32",
+ "@vue/runtime-core": "3.5.32",
+ "@vue/runtime-dom": "3.5.32",
+ "@vue/server-renderer": "3.5.32",
+ "@vue/shared": "3.5.32",
"vite": "npm:rolldown-vite@7.3.1",
"superdoc": "workspace:*",
"@superdoc-dev/react": "workspace:*",
diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts
index e63e4e95d2..7dc794c592 100644
--- a/packages/layout-engine/contracts/src/column-layout.ts
+++ b/packages/layout-engine/contracts/src/column-layout.ts
@@ -19,6 +19,7 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout {
gap: columns.gap,
...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}),
...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}),
+ ...(columns.withSeparator !== undefined ? { withSeparator: columns.withSeparator } : {}),
}
: { count: 1, gap: 0 };
}
@@ -62,6 +63,7 @@ export function normalizeColumnLayout(
count: 1,
gap: 0,
width: Math.max(0, contentWidth),
+ ...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
};
}
@@ -70,6 +72,7 @@ export function normalizeColumnLayout(
gap,
...(widths.length > 0 ? { widths } : {}),
...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}),
+ ...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
width,
};
}
diff --git a/packages/layout-engine/contracts/src/engines/tabs.test.ts b/packages/layout-engine/contracts/src/engines/tabs.test.ts
index 427d1abc36..e9c8d0b223 100644
--- a/packages/layout-engine/contracts/src/engines/tabs.test.ts
+++ b/packages/layout-engine/contracts/src/engines/tabs.test.ts
@@ -161,11 +161,49 @@ describe('engines-tabs computeTabStops', () => {
expect(stops.find((stop) => stop.pos === 340)).toBeDefined();
// Explicit stop at 709 should be preserved
expect(stops.find((stop) => stop.pos === 709)).toBeDefined();
- // First default should be at 709 + 720 = 1429
+ // First default should align with Word's 0.5" grid offset from leftIndent (709 + 720 = 1429).
expect(stops.find((stop) => stop.pos === 1429)).toBeDefined();
- // No default at 720 (before leftIndent, and no explicit stop there)
+ // No duplicate default at 720 because explicit stop at 709 occupies that slot
expect(stops.filter((stop) => stop.pos === 720).length).toBe(0);
});
+
+ it('still generates default start tabs before explicit right tabs (TOC regression)', () => {
+ const stops = computeTabStops({
+ explicitStops: [{ val: 'end', pos: 10593, leader: 'dot' }], // TOC1 style tab
+ defaultTabInterval: 720,
+ paragraphIndent: { left: 454, hanging: 454 }, // first line begins near 0"
+ });
+
+ const firstDefault = stops.find((stop) => stop.val === 'start' && stop.leader === 'none');
+ expect(firstDefault).toBeDefined();
+ expect(firstDefault?.pos).toBe(720); // Word default 0.5" tab stop
+ expect(firstDefault!.pos).toBeLessThan(10593);
+ expect(stops.find((stop) => stop.val === 'end' && stop.pos === 10593)).toBeDefined();
+ });
+
+ it('preserves legacy defaults-after-rightmost behavior when a start stop is present', () => {
+ // Paragraphs with a start-aligned explicit stop (e.g. signature lines, invoice
+ // headers) must keep the pre-fix behavior: defaults begin after the rightmost
+ // explicit stop, not from zero. Regression guard for the hasStartAlignedExplicit
+ // branch added alongside the TOC fix.
+ const explicitStops = [
+ { val: 'start' as const, pos: 500, leader: 'none' as const },
+ { val: 'end' as const, pos: 5000, leader: 'dot' as const },
+ ];
+ const stops = computeTabStops({
+ explicitStops,
+ defaultTabInterval: 720,
+ paragraphIndent: { left: 0 },
+ });
+
+ const explicitPositions = new Set(explicitStops.map((s) => s.pos));
+ // No *default* (non-explicit) stop should appear between 0 and the rightmost
+ // explicit stop (5000). Explicit stops themselves are allowed.
+ const generatedBelowEnd = stops.filter((stop) => stop.pos < 5000 && !explicitPositions.has(stop.pos));
+ expect(generatedBelowEnd).toHaveLength(0);
+ // Defaults should resume at 5720 (5000 + 720 interval).
+ expect(stops.find((stop) => stop.val === 'start' && stop.pos === 5720)).toBeDefined();
+ });
});
describe('engines-tabs layoutWithTabs', () => {
diff --git a/packages/layout-engine/contracts/src/engines/tabs.ts b/packages/layout-engine/contracts/src/engines/tabs.ts
index 9417ff5cb3..0a2ac0d884 100644
--- a/packages/layout-engine/contracts/src/engines/tabs.ts
+++ b/packages/layout-engine/contracts/src/engines/tabs.ts
@@ -129,18 +129,18 @@ export function computeTabStops(context: TabContext): TabStop[] {
// Find the rightmost explicit stop (use original stops for this calculation)
const maxExplicit = filteredExplicitStops.reduce((max, stop) => Math.max(max, stop.pos), 0);
- const hasExplicit = filteredExplicitStops.length > 0;
-
// Collect all stops: start with filtered explicit stops
const stops = [...filteredExplicitStops];
+ const hasStartAlignedExplicit = filteredExplicitStops.some((stop) => stop.val === 'start');
// Generate default stops at regular intervals.
- // When explicit stops exist, start after the rightmost explicit or leftIndent.
- // When no explicit stops, generate from 0 to ensure we hit multiples that land at/near leftIndent.
- // Then filter defaults by leftIndent (body text alignment).
- const defaultStart = hasExplicit ? Math.max(maxExplicit, leftIndent) : 0;
+ // - When no explicit start tabs exist (e.g., TOC paragraphs with only right-aligned tabs),
+ // seed defaults from the origin so numbering/content still lands on the default grid.
+ // - Otherwise, preserve legacy behavior: defaults start after the rightmost explicit or left indent.
+ const seedDefaultsFromZero = !hasStartAlignedExplicit;
+ const defaultStart = seedDefaultsFromZero ? 0 : Math.max(maxExplicit, leftIndent);
let pos = defaultStart;
- const targetLimit = Math.max(defaultStart, leftIndent) + 14400; // 14400 twips = 10 inches
+ const targetLimit = Math.max(defaultStart, leftIndent, maxExplicit) + 14400; // 14400 twips = 10 inches
while (pos < targetLimit) {
pos += defaultTabInterval;
diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts
index 69ca4dc0cc..4cc339e09c 100644
--- a/packages/layout-engine/contracts/src/index.ts
+++ b/packages/layout-engine/contracts/src/index.ts
@@ -987,10 +987,7 @@ export type SectionBreakBlock = {
even?: string;
odd?: string;
};
- columns?: {
- count: number;
- gap: number;
- widths?: number[];
+ columns?: ColumnLayout & {
equalWidth?: boolean;
};
/**
@@ -1478,10 +1475,28 @@ export type FlowBlock =
export type ColumnLayout = {
count: number;
gap: number;
+ withSeparator?: boolean;
widths?: number[];
equalWidth?: boolean;
};
+/**
+ * A vertical region of a page that shares a single column configuration.
+ *
+ * Continuous section breaks can introduce multiple column configurations on the
+ * same page (see ECMA-376 §17.6.22 and §17.18.77). A page may therefore carry
+ * multiple regions stacked vertically. Consumers (e.g. DomPainter) use
+ * `yStart`/`yEnd` to bound any per-region overlays such as column separators.
+ */
+export type ColumnRegion = {
+ /** Inclusive top of the region, in pixels from the page top. */
+ yStart: number;
+ /** Exclusive bottom of the region, in pixels from the page top. */
+ yEnd: number;
+ /** Column configuration active within this region. */
+ columns: ColumnLayout;
+};
+
/** A measured line within a block, output by the measurer. */
export type Line = {
fromRun: number;
@@ -1706,6 +1721,29 @@ export type Page = {
* Sections are 0-indexed, matching the sectionIndex in SectionMetadata.
*/
sectionIndex?: number;
+ /**
+ * Column layout configuration for this page.
+ *
+ * Reflects the column configuration at page start. For pages with continuous
+ * section breaks that change column layout mid-page, use `columnRegions` for
+ * accurate per-region information.
+ *
+ * Used by the renderer to draw column separator lines when `withSeparator`
+ * is set to true.
+ */
+ columns?: ColumnLayout;
+ /**
+ * Vertical column regions on this page, ordered top to bottom.
+ *
+ * Populated when continuous section breaks change column layout mid-page. Each
+ * region pairs a `{yStart, yEnd}` span with the column config active inside it
+ * (see ECMA-376 §17.6.22). Renderers should prefer this field over
+ * `columns` when drawing per-region overlays (e.g. column separators).
+ *
+ * If omitted, the page has a single column region and consumers can fall back
+ * to `columns`.
+ */
+ columnRegions?: ColumnRegion[];
};
/** A paragraph fragment positioned on a page. */
diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
index 4f82c339fc..79a4423c87 100644
--- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
+++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts
@@ -19,6 +19,7 @@ import {
resolvePageNumberTokens,
type NumberingContext,
SEMANTIC_PAGE_HEIGHT_PX,
+ SINGLE_COLUMN_DEFAULT,
} from '@superdoc/layout-engine';
import { remeasureParagraph } from './remeasure';
import { computeDirtyRegions } from './diff';
@@ -183,7 +184,7 @@ const resolvePageColumns = (layout: Layout, options: LayoutOptions, blocks?: Flo
);
const contentWidth = pageSize.w - (marginLeft + marginRight);
const sectionIndex = page.sectionIndex ?? 0;
- const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? { count: 1, gap: 0 };
+ const columnsConfig = sectionColumns.get(sectionIndex) ?? options.columns ?? SINGLE_COLUMN_DEFAULT;
const normalized = normalizeColumnsForFootnotes(columnsConfig, contentWidth);
result.set(pageIndex, { ...normalized, left: marginLeft, contentWidth });
}
@@ -1328,6 +1329,34 @@ export async function incrementalLayout(
const columns = pageColumns.get(pageIndex);
const columnCount = Math.max(1, Math.floor(columns?.count ?? 1));
+ // SD-1680: cap placement to the footnote demand on this page (capped by maxReserve).
+ // Demand = sum of measured heights of all footnote refs anchored here, plus the
+ // separator/padding/gap overhead they would incur when stacked. Capping placement
+ // at `min(demand, maxReserve)` (rather than `baseReserve`) decouples the plan's
+ // placement from the body's prior-pass reserve: the plan reports how much band
+ // the footnotes actually need, the body grows its reserve to match on the next
+ // pass, and placement never exceeds maxReserve so footnotes cannot render past
+ // the page's bottom margin.
+ let demand = 0;
+ for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
+ const ids = idsByColumn.get(pageIndex)?.get(columnIndex) ?? [];
+ let columnDemand = 0;
+ ids.forEach((id, idx) => {
+ const ranges = rangesByFootnoteId.get(id) ?? [];
+ let rangesHeight = 0;
+ ranges.forEach((range) => {
+ const spacingAfter = 'spacingAfter' in range ? (range.spacingAfter ?? 0) : 0;
+ rangesHeight += range.height + spacingAfter;
+ });
+ columnDemand += rangesHeight + (idx > 0 ? safeGap : 0);
+ });
+ if (columnDemand > 0) {
+ columnDemand += safeSeparatorSpacingBefore + safeDividerHeight + safeTopPadding;
+ }
+ if (columnDemand > demand) demand = columnDemand;
+ }
+ const placementCeiling = demand > 0 ? Math.min(Math.ceil(demand), maxReserve) : maxReserve;
+
const pendingForPage = new Map>();
pendingByColumn.forEach((entries, columnIndex) => {
const targetIndex = columnIndex < columnCount ? columnIndex : Math.max(0, columnCount - 1);
@@ -1365,7 +1394,7 @@ export async function incrementalLayout(
: 0;
const overhead = isFirstSlice ? separatorBefore + separatorHeight + safeTopPadding : 0;
const gapBefore = !isFirstSlice ? safeGap : 0;
- const availableHeight = Math.max(0, maxReserve - usedHeight - overhead - gapBefore);
+ const availableHeight = Math.max(0, placementCeiling - usedHeight - overhead - gapBefore);
const { slice, remainingRanges } = fitFootnoteContent(
id,
ranges,
@@ -1374,7 +1403,7 @@ export async function incrementalLayout(
columnIndex,
isContinuation,
measuresById,
- isFirstSlice && maxReserve > 0,
+ isFirstSlice && placementCeiling > 0,
);
if (slice.ranges.length === 0) {
@@ -1503,7 +1532,7 @@ export async function incrementalLayout(
);
const pageContentWidth = pageSize.w - (marginLeft + marginRight);
const fallbackColumns = normalizeColumnsForFootnotes(
- options.columns ?? { count: 1, gap: 0 },
+ options.columns ?? SINGLE_COLUMN_DEFAULT,
pageContentWidth,
);
const columns = pageColumns.get(pageIndex) ?? {
@@ -1756,7 +1785,7 @@ export async function incrementalLayout(
// so each page gets the correct reserve (avoids "too much" on one page and "not enough" on another).
if (reserves.some((h) => h > 0)) {
let reservesStabilized = false;
- const seenReserveKeys = new Set([reserves.join(',')]);
+ const seenReserveVectors: number[][] = [reserves.slice()];
for (let pass = 0; pass < MAX_FOOTNOTE_LAYOUT_PASSES; pass += 1) {
layout = relayout(reserves);
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
@@ -1772,13 +1801,33 @@ export async function incrementalLayout(
reservesStabilized = true;
break;
}
- // Detect oscillation: if we've produced a reserve vector we already tried,
- // the loop will never converge. Break early to avoid wasted relayout passes.
+ // SD-1680: when reserves oscillate (typically between a state where all footnotes
+ // fit and a state where body packs tighter with some footnotes pushed off the
+ // page), prefer the element-wise max across all seen states. This matches Word's
+ // bias toward keeping footnotes on their ref's page rather than tight body
+ // packing, and avoids overflow from the body reserving less than the plan places.
const nextKey = nextReserves.join(',');
- if (seenReserveKeys.has(nextKey)) {
+ const seen = seenReserveVectors.some((v) => v.join(',') === nextKey);
+ if (seen) {
+ const allVectors = [...seenReserveVectors, nextReserves];
+ const mergedLength = Math.max(...allVectors.map((v) => v.length));
+ const merged = new Array(mergedLength).fill(0);
+ for (const vec of allVectors) {
+ for (let i = 0; i < mergedLength; i += 1) {
+ if ((vec[i] ?? 0) > merged[i]) merged[i] = vec[i];
+ }
+ }
+ reserves = merged;
+ // Relayout with merged reserves so post-loop sees a layout consistent with the
+ // reserves we're about to apply — otherwise pages may collapse to the layout
+ // built with the smaller oscillating reserve.
+ layout = relayout(reserves);
+ ({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
+ ({ measuresById } = await measureFootnoteBlocks(collectFootnoteIdsByColumn(idsByColumn)));
+ plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, reserves, pageColumns);
break;
}
- seenReserveKeys.add(nextKey);
+ seenReserveVectors.push(nextReserves.slice());
// Only update reserves when we will do another layout pass; otherwise layout
// would be built with the previous reserves while reserves would be nextReserves,
// and the plan/injection phase could place footnotes in the wrong band.
@@ -1803,15 +1852,31 @@ export async function incrementalLayout(
reserves,
finalPageColumns,
);
- const finalReserves = finalPlan.reserves;
let reservesAppliedToLayout = reserves;
- const reservesDiffer =
- finalReserves.length !== reserves.length ||
- finalReserves.some((h, i) => (reserves[i] ?? 0) !== h) ||
- reserves.some((h, i) => (finalReserves[i] ?? 0) !== h);
- if (reservesDiffer) {
- layout = relayout(finalReserves);
- reservesAppliedToLayout = finalReserves;
+ // SD-1680: the post-loop can still mismatch the body reserve and plan placement when
+ // relayouting with finalPlan.reserves shifts footnote refs between pages (the newly
+ // relaxed page now holds refs the old reserves didn't account for). Iterate a few
+ // times, each step taking the element-wise max of current reserves and the new plan's
+ // reserves, so the final layout's reservation on every page is at least as large as
+ // the demand from the final ref assignment. This guarantees placements stay inside
+ // the band and cannot render past the page's bottom margin.
+ const MAX_POST_PASSES = 3;
+ for (let postPass = 0; postPass < MAX_POST_PASSES; postPass += 1) {
+ const target = reservesAppliedToLayout.slice();
+ const planReserves = finalPlan.reserves;
+ const len = Math.max(target.length, planReserves.length);
+ let needsRelayout = false;
+ for (let i = 0; i < len; i += 1) {
+ const applied = target[i] ?? 0;
+ const needed = planReserves[i] ?? 0;
+ if (needed > applied) {
+ target[i] = needed;
+ needsRelayout = true;
+ }
+ }
+ if (!needsRelayout) break;
+ layout = relayout(target);
+ reservesAppliedToLayout = target;
({ columns: finalPageColumns, idsByColumn: finalIdsByColumn } = resolveFootnoteAssignments(layout));
({ blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(
collectFootnoteIdsByColumn(finalIdsByColumn),
diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts
index 5e7ae01e43..0462bbecd5 100644
--- a/packages/layout-engine/layout-bridge/src/remeasure.ts
+++ b/packages/layout-engine/layout-bridge/src/remeasure.ts
@@ -5,6 +5,7 @@ import type {
LineSegment,
Run,
TextRun,
+ TabRun,
TabStop,
ParagraphIndent,
LeaderDecoration,
@@ -781,6 +782,33 @@ const applyTabLayoutToLines = (
indentLeft: number,
rawFirstLineOffset: number,
): void => {
+ const totalTabRuns = runs.reduce((count, run) => (run.kind === 'tab' ? count + 1 : count), 0);
+ const alignmentTabStopsPx = tabStops
+ .map((stop, index) => ({ stop, index }))
+ .filter(({ stop }) => stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal');
+ // Word-compat heuristic (not ECMA-376 17.3.3.32): the last N tab characters in a
+ // paragraph bind to the last N explicit end/center/decimal stops. Needed for TOC
+ // entries where a right-aligned dot-leader stop coexists with default grid stops.
+ // Mirrored in measuring/dom/src/index.ts.
+ const getAlignmentStopForOrdinal = (ordinal: number): { stop: TabStopPx; index: number } | null => {
+ if (alignmentTabStopsPx.length === 0 || totalTabRuns === 0 || !Number.isFinite(ordinal)) return null;
+ if (ordinal < 0 || ordinal >= totalTabRuns) return null;
+ const remainingTabs = totalTabRuns - ordinal - 1;
+ const targetIndex = alignmentTabStopsPx.length - 1 - remainingTabs;
+ if (targetIndex < 0 || targetIndex >= alignmentTabStopsPx.length) return null;
+ return alignmentTabStopsPx[targetIndex];
+ };
+ let sequentialTabIndex = 0;
+ const consumeTabOrdinal = (explicitIndex?: number): number => {
+ if (typeof explicitIndex === 'number' && Number.isFinite(explicitIndex)) {
+ sequentialTabIndex = Math.max(sequentialTabIndex, explicitIndex + 1);
+ return explicitIndex;
+ }
+ const ordinal = sequentialTabIndex;
+ sequentialTabIndex += 1;
+ return ordinal;
+ };
+
lines.forEach((line, lineIndex) => {
let cursorX = 0;
let lineWidth = 0;
@@ -797,11 +825,23 @@ const applyTabLayoutToLines = (
/**
* Processes a tab character, calculating position and handling alignment.
*/
- const applyTab = (startRunIndex: number, startChar: number, run?: Run): void => {
+ const applyTab = (startRunIndex: number, startChar: number, run?: Run, tabOrdinal?: number): void => {
const originX = cursorX;
const absCurrentX = cursorX + effectiveIndent;
- const { target, nextIndex, stop } = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
- tabStopCursor = nextIndex;
+ let stop: TabStopPx | undefined;
+ let target: number;
+ const forcedAlignment =
+ typeof tabOrdinal === 'number' && Number.isFinite(tabOrdinal) ? getAlignmentStopForOrdinal(tabOrdinal) : null;
+ if (forcedAlignment && forcedAlignment.stop.pos > absCurrentX + TAB_EPSILON) {
+ stop = forcedAlignment.stop;
+ target = forcedAlignment.stop.pos;
+ tabStopCursor = forcedAlignment.index + 1;
+ } else {
+ const next = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
+ stop = next.stop;
+ target = next.target;
+ tabStopCursor = next.nextIndex;
+ }
const clampedTarget = Number.isFinite(maxAbsWidth) ? Math.min(target, maxAbsWidth) : target;
const relativeTarget = clampedTarget - effectiveIndent;
lineWidth = Math.max(lineWidth, relativeTarget);
@@ -853,7 +893,9 @@ const applyTabLayoutToLines = (
const run = runs[runIndex];
if (!run) continue;
if (run.kind === 'tab') {
- applyTab(runIndex + 1, 0, run);
+ const tabRun = run as TabRun;
+ const ordinal = consumeTabOrdinal(tabRun.tabIndex);
+ applyTab(runIndex + 1, 0, run, ordinal);
continue;
}
@@ -889,7 +931,8 @@ const applyTabLayoutToLines = (
lineWidth = Math.max(lineWidth, cursorX);
segments.push(segment);
}
- applyTab(runIndex, i + 1);
+ const ordinal = consumeTabOrdinal();
+ applyTab(runIndex, i + 1, undefined, ordinal);
segmentStart = i + 1;
}
diff --git a/packages/layout-engine/layout-bridge/test/footnoteBandOverflow.test.ts b/packages/layout-engine/layout-bridge/test/footnoteBandOverflow.test.ts
new file mode 100644
index 0000000000..d70db31293
--- /dev/null
+++ b/packages/layout-engine/layout-bridge/test/footnoteBandOverflow.test.ts
@@ -0,0 +1,224 @@
+/**
+ * SD-1680: Footnotes render past the bottom page margin when multiple footnotes
+ * need more total height than the reserved band allows. Verifies that the plan
+ * output is internally consistent with the body layout's bottom margin — i.e.,
+ * no footnote fragment's bottom-Y exceeds the top of the physical bottom margin.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import type { FlowBlock, Measure, Fragment } from '@superdoc/contracts';
+import { incrementalLayout } from '../src/incrementalLayout';
+
+const makeParagraph = (id: string, text: string, pmStart: number): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart, pmEnd: pmStart + text.length }],
+});
+
+const makeSingleLineMeasure = (lineHeight: number, textLength: number): Measure => ({
+ kind: 'paragraph',
+ lines: [
+ {
+ fromRun: 0,
+ fromChar: 0,
+ toRun: 0,
+ toChar: textLength,
+ width: 200,
+ ascent: lineHeight * 0.8,
+ descent: lineHeight * 0.2,
+ lineHeight,
+ },
+ ],
+ totalHeight: lineHeight,
+});
+
+const makeMultiLineMeasure = (lineHeight: number, lineCount: number): Measure => {
+ const lines = Array.from({ length: lineCount }, (_, i) => ({
+ fromRun: 0,
+ fromChar: i,
+ toRun: 0,
+ toChar: i + 1,
+ width: 200,
+ ascent: lineHeight * 0.8,
+ descent: lineHeight * 0.2,
+ lineHeight,
+ }));
+ return { kind: 'paragraph', lines, totalHeight: lineCount * lineHeight };
+};
+
+const PAGE_PHYSICAL_BOTTOM_MARGIN = 72;
+const BODY_LINE_HEIGHT = 20;
+const FOOTNOTE_LINE_HEIGHT = 12;
+
+describe('SD-1680: Footnote band must not overflow the reserved area', () => {
+ /**
+ * Scenario: multiple medium-size footnotes compete for a single page's band.
+ * Page body fills to have all 4 refs on page 1. Each footnote is long enough that
+ * the total needed reserve exceeds what a single pass' body layout anticipated.
+ * Before the fix, footnotes stacked past the reserved band and rendered past the
+ * page's physical bottom margin. After the fix, excess footnotes must be
+ * pushed to a later page (or split) and the band must stay within its reserve.
+ */
+ it('keeps every footnote fragment within the band on multi-footnote pages', async () => {
+ // Realistic-ish scenario: ~20 body paragraphs across 2-3 pages, with 2 footnote
+ // refs per page. Each footnote is medium-sized (4 lines). Ample body slack so
+ // the layout converges within MAX_FOOTNOTE_LAYOUT_PASSES.
+ const BODY_PARAS = 20;
+ const FOOTNOTE_LINES = 4;
+ const FOOTNOTE_COUNT = 4;
+
+ let pos = 0;
+ const bodyBlocks: FlowBlock[] = [];
+ const refs: Array<{ id: string; pos: number }> = [];
+ const blocksById = new Map();
+
+ for (let i = 0; i < BODY_PARAS; i += 1) {
+ const text = `Body paragraph ${i + 1}.`;
+ const para = makeParagraph(`body-${i}`, text, pos);
+ bodyBlocks.push(para);
+
+ // Spread refs so 2 land on each page
+ if (refs.length < FOOTNOTE_COUNT && i % 3 === 1) {
+ const refId = String(refs.length + 1);
+ refs.push({ id: refId, pos: pos + 2 });
+ const fnBlock = makeParagraph(
+ `footnote-${refId}-0-paragraph`,
+ `Footnote ${refId} content spanning multiple lines.`,
+ 0,
+ );
+ blocksById.set(refId, [fnBlock]);
+ }
+
+ pos += text.length + 1;
+ }
+
+ const measureBlock = vi.fn(async (block: FlowBlock) => {
+ if (block.id.startsWith('footnote-')) {
+ return makeMultiLineMeasure(FOOTNOTE_LINE_HEIGHT, FOOTNOTE_LINES);
+ }
+ return makeSingleLineMeasure(BODY_LINE_HEIGHT, 20);
+ });
+
+ // Page holds ~10 body paragraphs (200px). With footnote reserve, body shrinks
+ // enough that refs can move between pages, but everything must stay bounded.
+ const contentHeight = 10 * BODY_LINE_HEIGHT; // 200px
+ const pageHeight = contentHeight + 72 + PAGE_PHYSICAL_BOTTOM_MARGIN;
+
+ const result = await incrementalLayout(
+ [],
+ null,
+ bodyBlocks,
+ {
+ pageSize: { w: 612, h: pageHeight },
+ margins: { top: 72, right: 72, bottom: PAGE_PHYSICAL_BOTTOM_MARGIN, left: 72 },
+ footnotes: {
+ refs,
+ blocksById,
+ topPadding: 4,
+ dividerHeight: 2,
+ },
+ },
+ measureBlock,
+ );
+
+ const { layout } = result;
+ const pageH = pageHeight;
+
+ // INVARIANT: on every page, every footnote fragment's bottom edge must be ≤ the
+ // top of the physical bottom margin (pageH - PAGE_PHYSICAL_BOTTOM_MARGIN).
+ // Anything below that point has rendered past the reserved band into the
+ // footer area — the core SD-1680 bug.
+ const overflows: string[] = [];
+ for (const page of layout.pages) {
+ const pageBottomLimit = (page.size?.h ?? pageH) - PAGE_PHYSICAL_BOTTOM_MARGIN;
+ const footnoteFragments = page.fragments.filter(
+ (f: Fragment) =>
+ typeof f.blockId === 'string' && f.blockId.startsWith('footnote-') && !f.blockId.includes('separator'),
+ );
+ for (const f of footnoteFragments) {
+ // For paragraph fragments, height is computed from fromLine/toLine * lineHeight.
+ // The footnote measure's totalHeight is FOOTNOTE_LINES * FOOTNOTE_LINE_HEIGHT.
+ let fragHeight = 0;
+ if (f.kind === 'para' && typeof f.toLine === 'number' && typeof f.fromLine === 'number') {
+ fragHeight = (f.toLine - f.fromLine) * FOOTNOTE_LINE_HEIGHT;
+ } else if (typeof (f as { height?: number }).height === 'number') {
+ fragHeight = (f as { height: number }).height;
+ }
+ const fragBottom = (f.y ?? 0) + fragHeight;
+ if (fragBottom > pageBottomLimit + 1) {
+ overflows.push(
+ `page ${page.number}: fragment ${f.blockId} bottom=${fragBottom.toFixed(1)} exceeds limit ${pageBottomLimit.toFixed(1)} by ${(fragBottom - pageBottomLimit).toFixed(1)}px`,
+ );
+ }
+ }
+ }
+
+ expect(overflows).toEqual([]);
+ });
+
+ /**
+ * A single huge footnote (taller than the page's max reserve) must be split
+ * across pages with a continuation on the next page — never rendered as one
+ * monolithic block that extends off the page.
+ */
+ it('splits an oversized footnote across pages rather than overflowing', async () => {
+ const BIG_FOOTNOTE_LINES = 30; // way bigger than a single page's band
+ const refPos = 5;
+
+ const bodyBlocks: FlowBlock[] = [makeParagraph('body-0', 'Body before footnote ref.', 0)];
+ const footnoteBlock = makeParagraph(
+ 'footnote-1-0-paragraph',
+ 'A very long footnote that should not fit on a single page band.',
+ 0,
+ );
+
+ const measureBlock = vi.fn(async (block: FlowBlock) => {
+ if (block.id.startsWith('footnote-')) {
+ return makeMultiLineMeasure(FOOTNOTE_LINE_HEIGHT, BIG_FOOTNOTE_LINES);
+ }
+ return makeSingleLineMeasure(BODY_LINE_HEIGHT, 26);
+ });
+
+ const contentHeight = 200;
+ const pageHeight = contentHeight + 72 + PAGE_PHYSICAL_BOTTOM_MARGIN;
+
+ const result = await incrementalLayout(
+ [],
+ null,
+ bodyBlocks,
+ {
+ pageSize: { w: 612, h: pageHeight },
+ margins: { top: 72, right: 72, bottom: PAGE_PHYSICAL_BOTTOM_MARGIN, left: 72 },
+ footnotes: {
+ refs: [{ id: '1', pos: refPos }],
+ blocksById: new Map([['1', [footnoteBlock]]]),
+ topPadding: 4,
+ dividerHeight: 2,
+ },
+ },
+ measureBlock,
+ );
+
+ const { layout } = result;
+ const pageBottomLimit = pageHeight - PAGE_PHYSICAL_BOTTOM_MARGIN;
+
+ // No single fragment may extend past the page's bottom limit.
+ for (const page of layout.pages) {
+ const pageBottomThisPage = (page.size?.h ?? pageHeight) - PAGE_PHYSICAL_BOTTOM_MARGIN;
+ const footnoteFragments = page.fragments.filter(
+ (f: Fragment) =>
+ typeof f.blockId === 'string' && f.blockId.startsWith('footnote-') && !f.blockId.includes('separator'),
+ );
+ for (const f of footnoteFragments) {
+ let fragHeight = 0;
+ if (f.kind === 'para' && typeof f.toLine === 'number' && typeof f.fromLine === 'number') {
+ fragHeight = (f.toLine - f.fromLine) * FOOTNOTE_LINE_HEIGHT;
+ } else if (typeof (f as { height?: number }).height === 'number') {
+ fragHeight = (f as { height: number }).height;
+ }
+ const fragBottom = (f.y ?? 0) + fragHeight;
+ expect(fragBottom).toBeLessThanOrEqual(pageBottomThisPage + 1);
+ }
+ }
+ });
+});
diff --git a/packages/layout-engine/layout-bridge/test/remeasure.test.ts b/packages/layout-engine/layout-bridge/test/remeasure.test.ts
index f7e757e55a..66d5a2ee1a 100644
--- a/packages/layout-engine/layout-bridge/test/remeasure.test.ts
+++ b/packages/layout-engine/layout-bridge/test/remeasure.test.ts
@@ -445,6 +445,27 @@ describe('remeasureParagraph', () => {
expect(measure.lines[0].segments?.length).toBeGreaterThan(0);
});
+ it('aligns trailing TOC-style tab to explicit right stop with leader', () => {
+ const rightStopPx = 300;
+ const block = createBlock(
+ [textRun('1.'), tabRun({ tabIndex: 0 }), textRun('Generalities'), tabRun({ tabIndex: 1 }), textRun('5')],
+ {
+ tabs: [{ pos: pxToTwips(rightStopPx), val: 'end', leader: 'dot' }],
+ indent: { left: 30, hanging: 30 },
+ tabIntervalTwips: DEFAULT_TAB_INTERVAL_TWIPS,
+ },
+ );
+
+ const measure = remeasureParagraph(block, 800);
+ expect(measure.lines).toHaveLength(1);
+ const leaders = measure.lines[0].leaders;
+ expect(leaders).toBeDefined();
+ expect(leaders?.length).toBe(1);
+ const leader = leaders![0];
+ expect(leader.style).toBe('dot');
+ expect(leader.to).toBeCloseTo(rightStopPx - CHAR_WIDTH, 0);
+ });
+
it('handles tab at various positions within text', () => {
// Tab after some text should advance to next stop after current position
const tabStop: TabStop = { pos: 720, val: 'start' }; // 48px
@@ -481,7 +502,6 @@ describe('remeasureParagraph', () => {
const tabStop: TabStop = { pos: 1440, val: 'start' };
const block = createBlock([textRun('A'), tabRun(), textRun('B')], { tabs: [tabStop] });
const measure = remeasureParagraph(block, 200);
-
expect(measure.lines).toHaveLength(1);
// Tab should advance to 96px (1 inch)
expect(measure.lines[0].width).toBeGreaterThan(96);
diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts
index ab883680e2..84ff86b583 100644
--- a/packages/layout-engine/layout-engine/src/index.test.ts
+++ b/packages/layout-engine/layout-engine/src/index.test.ts
@@ -228,6 +228,125 @@ describe('layoutDocument', () => {
expect(layout.columns).toMatchObject({ count: 2, gap: 20 });
});
+ it('sets "page.columns" with separator when column separator is enabled', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 40, right: 40, bottom: 40, left: 40 },
+ columns: { count: 2, gap: 20, withSeparator: true },
+ };
+ const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options);
+
+ expect(layout.pages).toHaveLength(1);
+ expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: true });
+ expect(layout.columns).toMatchObject({ count: 2, gap: 20, withSeparator: true });
+ });
+
+ it('preserves explicit column widths on page-level column metadata', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 40, right: 40, bottom: 40, left: 40 },
+ columns: { count: 2, gap: 20, widths: [100, 400], equalWidth: false, withSeparator: true },
+ };
+ const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options);
+
+ expect(layout.pages).toHaveLength(1);
+ expect(layout.pages[0].columns).toEqual({
+ count: 2,
+ gap: 20,
+ widths: [100, 400],
+ equalWidth: false,
+ withSeparator: true,
+ });
+ expect(layout.columns).toEqual({
+ count: 2,
+ gap: 20,
+ widths: [100, 400],
+ equalWidth: false,
+ withSeparator: true,
+ });
+ });
+
+ it('does not set "page.columns" on single column layout', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 40, right: 40, bottom: 40, left: 40 },
+ };
+ const layout = layoutDocument([block], [makeMeasure([350])], options);
+
+ expect(layout.pages).toHaveLength(1);
+ expect(layout.pages[0].columns).toBeUndefined();
+ expect(layout.columns).toBeUndefined();
+ });
+
+ it('sets "page.columns" without separator when column separator is not enabled', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 40, right: 40, bottom: 40, left: 40 },
+ columns: { count: 2, gap: 20, withSeparator: false },
+ };
+ const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options);
+
+ expect(layout.pages).toHaveLength(1);
+ expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, withSeparator: false });
+ expect(layout.columns).toEqual({ count: 2, gap: 20, withSeparator: false });
+ });
+
+ it('emits page.columnRegions for continuous section breaks that change column config mid-page', () => {
+ // Two sections on the same page: first 2-col with separator, then a
+ // continuous break that switches to 3-col still with separator. The
+ // layout engine should record a ConstraintBoundary and surface it on
+ // page.columnRegions so the renderer can bound each separator to the
+ // correct Y range.
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'intro', runs: [] },
+ {
+ kind: 'sectionBreak',
+ id: 'sb-continuous',
+ type: 'continuous',
+ columns: { count: 3, gap: 20, withSeparator: true },
+ },
+ { kind: 'paragraph', id: 'body', runs: [] },
+ ];
+ const measures: Measure[] = [makeMeasure([30]), { kind: 'sectionBreak' }, makeMeasure([30, 30, 30])];
+
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 40, right: 40, bottom: 40, left: 40 },
+ columns: { count: 2, gap: 20, withSeparator: true },
+ };
+
+ const layout = layoutDocument(blocks, measures, options);
+
+ expect(layout.pages).toHaveLength(1);
+ const regions = layout.pages[0].columnRegions;
+ expect(regions).toBeDefined();
+ expect(regions!.length).toBeGreaterThanOrEqual(2);
+ // First region covers the initial 2-col layout from topMargin to the boundary.
+ expect(regions![0].yStart).toBe(40);
+ expect(regions![0].columns).toEqual({ count: 2, gap: 20, withSeparator: true });
+ // Second region picks up the continuous break's 3-col config and ends at
+ // the bottom of the content area.
+ const last = regions![regions!.length - 1];
+ expect(last.columns).toMatchObject({ count: 3, gap: 20, withSeparator: true });
+ expect(last.yEnd).toBe(800 - 40);
+ // Regions must tile (no gaps, no overlap).
+ for (let i = 1; i < regions!.length; i++) {
+ expect(regions![i].yStart).toBe(regions![i - 1].yEnd);
+ }
+ });
+
+ it('omits page.columnRegions when no mid-page column change occurs', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 40, right: 40, bottom: 40, left: 40 },
+ columns: { count: 2, gap: 20, withSeparator: true },
+ };
+ const layout = layoutDocument([block], [makeMeasure([350, 350, 350])], options);
+
+ expect(layout.pages).toHaveLength(1);
+ expect(layout.pages[0].columnRegions).toBeUndefined();
+ });
+
it('applies spacing before and after paragraphs', () => {
const spacingBlock: FlowBlock = {
kind: 'paragraph',
@@ -1919,6 +2038,78 @@ describe('layoutDocument', () => {
expect(p3Fragment).toBeDefined();
expect(p3Fragment?.width).toBe(210); // Half width = two columns
});
+
+ it('starts new region below tallest column when columns have unequal heights', () => {
+ // Regression test for SD-1869: when a multi-column section has unequal column
+ // heights, the next region must start below the TALLEST column, not the last
+ // column's cursor. Without the maxCursorY fix, the new region would start at
+ // the shorter column's bottom, overlapping the taller one.
+ //
+ // Uses a 3-col → 2-col transition because the layout engine forces a new page
+ // when reducing to fewer columns than the current column index (guard at
+ // columnIndexBefore >= newColumns.count). With 3→2, content in col1
+ // (columnIndex=1) stays on the same page (1 < 2).
+ const toThreeColumns: FlowBlock = {
+ kind: 'sectionBreak',
+ id: 'sb-to-3col',
+ type: 'continuous',
+ columns: { count: 3, gap: 24 },
+ margins: {},
+ };
+ const toTwoColumns: FlowBlock = {
+ kind: 'sectionBreak',
+ id: 'sb-to-2col',
+ type: 'continuous',
+ columns: { count: 2, gap: 48 },
+ margins: {},
+ };
+
+ const blocks: FlowBlock[] = [
+ { kind: 'paragraph', id: 'p1', runs: [] }, // single column preamble
+ toThreeColumns,
+ { kind: 'paragraph', id: 'p-cols', runs: [] }, // 3 lines → col0 gets 2, col1 gets 1
+ toTwoColumns,
+ { kind: 'paragraph', id: 'p-after', runs: [] }, // must start below tallest column
+ ];
+
+ // p-cols: 3 lines of 250px each (750px total)
+ // Available column height = 720 (page bottom) - 112 (region top) = 608px
+ // Column 0 fits lines 0+1 (500px), line 2 overflows to column 1
+ // Column 0 bottom = 112 + 500 = 612
+ // Column 1 bottom = 112 + 250 = 362
+ const measures: Measure[] = [
+ makeMeasure([40]), // p1
+ { kind: 'sectionBreak' },
+ makeMeasure([250, 250, 250]), // p-cols: 3 lines, 2 in col0 + 1 in col1
+ { kind: 'sectionBreak' },
+ makeMeasure([40]), // p-after
+ ];
+
+ const options: LayoutOptions = {
+ pageSize: { w: 612, h: 792 },
+ margins: { top: 72, right: 72, bottom: 72, left: 72 },
+ };
+
+ const layout = layoutDocument(blocks, measures, options);
+
+ // Everything should fit on one page
+ expect(layout.pages.length).toBe(1);
+
+ // p1 at y=72, height=40 → region for 3-col section starts at y=112
+ const regionTop = 72 + 40; // 112
+
+ // Column 0: 2 lines × 250px = 500px → bottom at 112 + 500 = 612
+ // Column 1: 1 line × 250px = 250px → bottom at 112 + 250 = 362
+ const tallestColumnBottom = regionTop + 500; // 612
+
+ const page = layout.pages[0];
+ const pAfter = page.fragments.find((f) => f.blockId === 'p-after');
+ expect(pAfter).toBeDefined();
+
+ // KEY ASSERTION: p-after must start at or below the tallest column's bottom (612)
+ // Without the fix, it would start at 362 (column 1's bottom), overlapping column 0
+ expect(pAfter!.y).toBeGreaterThanOrEqual(tallestColumnBottom);
+ });
});
describe('columnBreak with multi-column pages', () => {
@@ -5421,3 +5612,312 @@ describe('requirePageBoundary edge cases', () => {
});
});
});
+
+describe('alternateHeaders (odd/even header differentiation)', () => {
+ // Two tall paragraphs (400px each) that force a 2-page layout.
+ const tallBlock = (id: string): FlowBlock => ({
+ kind: 'paragraph',
+ id,
+ runs: [],
+ });
+ const tallMeasure = makeMeasure([400]);
+
+ it('selects even/odd header heights when alternateHeaders is true', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ headerContentHeights: {
+ odd: 80, // Odd pages: header pushes body start down
+ even: 40, // Even pages: smaller header
+ },
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Page 1 is odd (documentPageNumber=1) → uses 'odd' header height (80px)
+ // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+80) = 110
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ expect(p1Fragment).toBeDefined();
+ expect(p1Fragment!.y).toBeCloseTo(110, 0);
+
+ // Page 2 is even (documentPageNumber=2) → uses 'even' header height (40px)
+ // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+40) = 70
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p2Fragment).toBeDefined();
+ expect(p2Fragment!.y).toBeCloseTo(70, 0);
+ });
+
+ it('uses default header height for all pages when alternateHeaders is false', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: false,
+ headerContentHeights: {
+ default: 60,
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Both pages use 'default' header height (60px)
+ // Body start = max(50, 30+60) = 90
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p1Fragment!.y).toBeCloseTo(90, 0);
+ expect(p2Fragment!.y).toBeCloseTo(90, 0);
+ });
+
+ it('defaults to false when alternateHeaders is omitted', () => {
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ // alternateHeaders not set
+ headerContentHeights: {
+ default: 60,
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Both pages should use 'default' (60px), not odd/even
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p1Fragment!.y).toBeCloseTo(90, 0);
+ expect(p2Fragment!.y).toBeCloseTo(90, 0);
+ });
+
+ it('first page uses first variant when titlePg is enabled with alternateHeaders', () => {
+ const sectionBreak: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb',
+ attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0, titlePg: true }],
+ headerContentHeights: {
+ first: 100, // First page: tallest header
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument(
+ [sectionBreak, tallBlock('p1'), tallBlock('p2'), tallBlock('p3')],
+ [{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, tallMeasure],
+ options,
+ );
+
+ expect(layout.pages.length).toBeGreaterThanOrEqual(3);
+
+ // Page 1 (first page of section, titlePg=true) → 'first' variant → 100px
+ // Body start = max(50, 30+100) = 130
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ expect(p1Fragment).toBeDefined();
+ expect(p1Fragment!.y).toBeCloseTo(130, 0);
+
+ // Page 2 (documentPageNumber=2, even) → 'even' variant → 40px
+ // Body start = max(50, 30+40) = 70
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p2Fragment).toBeDefined();
+ expect(p2Fragment!.y).toBeCloseTo(70, 0);
+
+ // Page 3 (documentPageNumber=3, odd) → 'odd' variant → 80px
+ // Body start = max(50, 30+80) = 110
+ const p3Fragment = layout.pages[2].fragments.find((f) => f.blockId === 'p3');
+ expect(p3Fragment).toBeDefined();
+ expect(p3Fragment!.y).toBeCloseTo(110, 0);
+ });
+
+ it('multi-section: uses document page number for even/odd, not section-relative', () => {
+ // Section 1 has 3 pages (pages 1-3), section 2 starts on page 4.
+ // Page 4 is even by document number, but sectionPageNumber=1 (odd).
+ // The fix ensures document page number is used for even/odd.
+ const sb1: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb1',
+ attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+ const sb2: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb2',
+ type: 'nextPage',
+ attrs: { source: 'sectPr', sectionIndex: 1 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0 }, { sectionIndex: 1 }],
+ headerContentHeights: {
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument(
+ [sb1, tallBlock('p1'), tallBlock('p2'), tallBlock('p3'), sb2, tallBlock('p4')],
+ [{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, tallMeasure, { kind: 'sectionBreak' }, tallMeasure],
+ options,
+ );
+
+ expect(layout.pages.length).toBeGreaterThanOrEqual(4);
+
+ // Page 4 (documentPageNumber=4, even) → should use 'even' header (40px)
+ // NOT 'odd' which would happen if sectionPageNumber (1) were used
+ // Body start = max(50, 30+40) = 70
+ const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4');
+ expect(p4Fragment).toBeDefined();
+ expect(p4Fragment!.y).toBeCloseTo(70, 0);
+ });
+
+ it('selects even/odd footer heights when alternateHeaders is true', () => {
+ // The footer-height path uses the per-rId map + sectionMetadata.footerRefs.
+ // Exposing the variant selection through `footerContentHeights` alone is not
+ // sufficient — without refs, the code falls back to 'default' for the footer
+ // variant regardless. We need the ref map to observe variant switching on
+ // `page.margins.bottom`.
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, footer: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0, footerRefs: { odd: 'rIdFooterOdd', even: 'rIdFooterEven' } }],
+ footerContentHeightsByRId: new Map([
+ ['rIdFooterOdd', 80], // Odd pages: larger footer
+ ['rIdFooterEven', 40], // Even pages: smaller footer
+ ]),
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Page 1 is odd → 'odd' footer (80px) → bottom = max(50, 30+80) = 110
+ // Page 2 is even → 'even' footer (40px) → bottom = max(50, 30+40) = 70
+ // Body-top Y is footer-independent, so assert on the effective bottom margin
+ // the paginator stamped on each page.
+ expect(layout.pages[0].margins?.bottom).toBeCloseTo(110, 0);
+ expect(layout.pages[1].margins?.bottom).toBeCloseTo(70, 0);
+ });
+
+ it('falls back to default header when only default is defined with alternateHeaders', () => {
+ // Production path: a document with `w:evenAndOddHeaders` on but only a
+ // `default` header authored. sectionMetadata supplies the `default` ref and
+ // the per-rId height map supplies its measurement. Step-3 fallback at
+ // index.ts:1345-1349 kicks in and `effectiveVariantType` drops to 'default'.
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rIdHeaderDefault' } }],
+ headerContentHeightsByRId: new Map([['rIdHeaderDefault', 60]]),
+ };
+
+ const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
+
+ expect(layout.pages).toHaveLength(2);
+
+ // Both pages fall back to the default header (60px), so body start is the
+ // same on odd and even: max(50, 30+60) = 90.
+ const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
+ const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
+ expect(p1Fragment!.y).toBeCloseTo(90, 0);
+ expect(p2Fragment!.y).toBeCloseTo(90, 0);
+ // Effective top margin is also 90 on both pages.
+ expect(layout.pages[0].margins?.top).toBeCloseTo(90, 0);
+ expect(layout.pages[1].margins?.top).toBeCloseTo(90, 0);
+ });
+
+ it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => {
+ // Most realistic mixed case. Section 1 has 3 pages (docPN 1-3). Section 2
+ // has titlePg=true and starts on docPN=4.
+ // - Page 4 is sectionPageNumber=1 for section 2 + titlePg=true → 'first'
+ // - Page 5 is docPN=5 (odd) → 'odd' (regardless of section-relative number)
+ // - Page 6 is docPN=6 (even) → 'even'
+ // If the code used sectionPageNumber for even/odd, pages 5 and 6 would be
+ // swapped (section-relative 2 and 3 respectively). This guards both titlePg
+ // and the docPN rule across a section boundary.
+ const sb1: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb1',
+ attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+ const sb2: SectionBreakBlock = {
+ kind: 'sectionBreak',
+ id: 'sb2',
+ type: 'nextPage',
+ attrs: { source: 'sectPr', sectionIndex: 1 },
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ };
+
+ const options: LayoutOptions = {
+ pageSize: { w: 600, h: 800 },
+ margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
+ alternateHeaders: true,
+ sectionMetadata: [{ sectionIndex: 0 }, { sectionIndex: 1, titlePg: true }],
+ headerContentHeights: {
+ first: 100, // section 2 title-page header
+ odd: 80,
+ even: 40,
+ },
+ };
+
+ const layout = layoutDocument(
+ [sb1, tallBlock('p1'), tallBlock('p2'), tallBlock('p3'), sb2, tallBlock('p4'), tallBlock('p5'), tallBlock('p6')],
+ [
+ { kind: 'sectionBreak' },
+ tallMeasure,
+ tallMeasure,
+ tallMeasure,
+ { kind: 'sectionBreak' },
+ tallMeasure,
+ tallMeasure,
+ tallMeasure,
+ ],
+ options,
+ );
+
+ expect(layout.pages.length).toBeGreaterThanOrEqual(6);
+
+ // Page 4: section 2 first page + titlePg → 'first' (100px) → y = max(50, 30+100) = 130
+ const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4');
+ expect(p4Fragment).toBeDefined();
+ expect(p4Fragment!.y).toBeCloseTo(130, 0);
+
+ // Page 5: docPN=5, odd → 'odd' (80px) → y = max(50, 30+80) = 110
+ // If sectionPageNumber were used: sectionPN=2 → 'even' (40) → y = 70 (wrong)
+ const p5Fragment = layout.pages[4]?.fragments.find((f) => f.blockId === 'p5');
+ expect(p5Fragment).toBeDefined();
+ expect(p5Fragment!.y).toBeCloseTo(110, 0);
+
+ // Page 6: docPN=6, even → 'even' (40px) → y = max(50, 30+40) = 70
+ // If sectionPageNumber were used: sectionPN=3 → 'odd' (80) → y = 110 (wrong)
+ const p6Fragment = layout.pages[5]?.fragments.find((f) => f.blockId === 'p6');
+ expect(p6Fragment).toBeDefined();
+ expect(p6Fragment!.y).toBeCloseTo(70, 0);
+ });
+});
diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts
index f2a384ee37..1b0574b964 100644
--- a/packages/layout-engine/layout-engine/src/index.ts
+++ b/packages/layout-engine/layout-engine/src/index.ts
@@ -1,5 +1,6 @@
import type {
ColumnLayout,
+ ColumnRegion,
FlowBlock,
Fragment,
HeaderFooterLayout,
@@ -35,6 +36,7 @@ import {
scheduleSectionBreak as scheduleSectionBreakExport,
type SectionState,
applyPendingToActive,
+ SINGLE_COLUMN_DEFAULT,
} from './section-breaks.js';
import { layoutParagraphBlock } from './layout-paragraph.js';
import { layoutImageBlock } from './layout-image.js';
@@ -526,6 +528,17 @@ export type LayoutOptions = {
* behavior for paragraph-free overlays.
*/
allowSectionBreakOnlyPageFallback?: boolean;
+ /**
+ * Whether the document has odd/even header/footer differentiation enabled.
+ * Corresponds to the w:evenAndOddHeaders element in OOXML settings.xml.
+ * When true, odd pages use the 'odd' variant and even pages use the 'even' variant.
+ * When false or omitted, all pages use the 'default' variant.
+ *
+ * Must stay in sync with `getHeaderFooterTypeForSection` in
+ * `layout-bridge/src/headerFooterUtils.ts` — both sides read this value
+ * and must agree on variant selection.
+ */
+ alternateHeaders?: boolean;
};
export type HeaderFooterConstraints = {
@@ -667,23 +680,29 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
/**
* Determines the header/footer variant type for a given page based on section settings.
*
- * @param sectionPageNumber - The page number within the current section (1-indexed)
+ * Takes a params object because the two page-number fields have very similar
+ * names and types — a positional call site is easy to get wrong.
+ *
+ * @param sectionPageNumber - The page number within the current section (1-indexed), used for titlePg
+ * @param documentPageNumber - The absolute document page number (1-indexed), used for even/odd
* @param titlePgEnabled - Whether the section has "different first page" enabled
- * @param alternateHeaders - Whether the section has odd/even differentiation enabled
+ * @param alternateHeaders - Whether the document has odd/even differentiation enabled
* @returns The variant type: 'first', 'even', 'odd', or 'default'
*/
- const getVariantTypeForPage = (
- sectionPageNumber: number,
- titlePgEnabled: boolean,
- alternateHeaders: boolean,
- ): 'default' | 'first' | 'even' | 'odd' => {
+ const getVariantTypeForPage = (args: {
+ sectionPageNumber: number;
+ documentPageNumber: number;
+ titlePgEnabled: boolean;
+ alternateHeaders: boolean;
+ }): 'default' | 'first' | 'even' | 'odd' => {
// First page of section with titlePg enabled uses 'first' variant
- if (sectionPageNumber === 1 && titlePgEnabled) {
+ if (args.sectionPageNumber === 1 && args.titlePgEnabled) {
return 'first';
}
- // Alternate headers (even/odd differentiation)
- if (alternateHeaders) {
- return sectionPageNumber % 2 === 0 ? 'even' : 'odd';
+ // Alternate headers: even/odd based on document page number, matching
+ // the rendering side (getHeaderFooterTypeForSection in headerFooterUtils.ts)
+ if (args.alternateHeaders) {
+ return args.documentPageNumber % 2 === 0 ? 'even' : 'odd';
}
return 'default';
};
@@ -1001,14 +1020,18 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (block.orientation) next.pendingOrientation = block.orientation;
const sectionType = block.type ?? 'continuous';
// Check if columns are changing: either explicitly to a different config,
- // or implicitly resetting to single column (undefined = single column in OOXML)
+ // or implicitly resetting to single column (undefined = single column in OOXML).
+ // withSeparator must be compared because a sep-only toggle still needs a new
+ // column region so the renderer can draw (or stop drawing) the separator from
+ // the toggle point onward.
const isColumnsChanging =
(block.columns &&
(block.columns.count !== next.activeColumns.count ||
block.columns.gap !== next.activeColumns.gap ||
+ Boolean(block.columns.withSeparator) !== Boolean(next.activeColumns.withSeparator) ||
block.columns.equalWidth !== next.activeColumns.equalWidth ||
!widthsEqual(block.columns.widths, next.activeColumns.widths))) ||
- (!block.columns && next.activeColumns.count > 1);
+ (!block.columns && (next.activeColumns.count > 1 || Boolean(next.activeColumns.withSeparator)));
// Schedule section index change for next page (enables section-aware page numbering)
const sectionIndexRaw = block.attrs?.sectionIndex;
const metadataIndex = typeof sectionIndexRaw === 'number' ? sectionIndexRaw : Number(sectionIndexRaw ?? NaN);
@@ -1074,6 +1097,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (activeOrientation) {
page.orientation = activeOrientation;
}
+
+ if (activeColumns.count > 1) {
+ page.columns = cloneColumnLayout(activeColumns);
+ }
+
// Set vertical alignment from active section state
if (activeVAlign && activeVAlign !== 'top') {
page.vAlign = activeVAlign;
@@ -1284,11 +1312,15 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// Get section metadata for titlePg setting
const sectionMetadata = sectionMetadataList[activeSectionIndex];
const titlePgEnabled = sectionMetadata?.titlePg ?? false;
- // TODO: Support alternateHeaders (odd/even) when needed
- const alternateHeaders = false;
+ const alternateHeaders = options.alternateHeaders ?? false;
// Determine which header/footer variant applies to this page
- const variantType = getVariantTypeForPage(sectionPageNumber, titlePgEnabled, alternateHeaders);
+ const variantType = getVariantTypeForPage({
+ sectionPageNumber,
+ documentPageNumber: newPageNumber,
+ titlePgEnabled,
+ alternateHeaders,
+ });
// Resolve header/footer refs for margin calculation using OOXML inheritance model.
// This must match the rendering logic in PresentationEditor to ensure margins
@@ -1440,9 +1472,16 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// Start a new mid-page region with different column configuration
const startMidPageRegion = (state: PageState, newColumns: ColumnLayout): void => {
- // Record the boundary at current Y position
+ // Use the maximum Y reached across all columns so the new region starts
+ // below ALL column content, not just the current column's cursor position.
+ // This prevents overlap when a multi-column section's columns have unequal heights.
+ const regionStartY = Math.max(state.cursorY, state.maxCursorY);
+ state.cursorY = regionStartY;
+ state.maxCursorY = regionStartY;
+
+ // Record the boundary at the resolved Y position
const boundary: ConstraintBoundary = {
- y: state.cursorY,
+ y: regionStartY,
columns: newColumns,
};
state.constraintBoundaries.push(boundary);
@@ -1454,7 +1493,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
layoutLog(`[Layout] *** COLUMNS CHANGED MID-PAGE ***`);
layoutLog(` OLD activeColumns: ${JSON.stringify(activeColumns)}`);
layoutLog(` NEW activeColumns: ${JSON.stringify(newColumns)}`);
- layoutLog(` Current page: ${state.page.number}, cursorY: ${state.cursorY}`);
+ layoutLog(` Current page: ${state.page.number}, cursorY: ${state.cursorY}, maxCursorY: ${state.maxCursorY}`);
// Update activeColumns so subsequent pages use this column configuration
activeColumns = cloneColumnLayout(newColumns);
@@ -1468,9 +1507,6 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
{ left: activeLeftMargin, right: activeRightMargin },
activePageSize.w,
);
-
- // Note: We do NOT reset cursorY - content continues from current position
- // This creates the mid-page region effect
};
// Collect anchored drawings mapped to their anchor paragraphs
@@ -2118,6 +2154,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
}
}
state.cursorY = tableBottomY;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
}
continue;
}
@@ -2527,6 +2564,39 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
}
}
+ // Serialize constraint boundaries into page.columnRegions so DomPainter can
+ // draw per-region overlays (e.g. column separator lines) bounded by the
+ // correct Y span. Continuous section breaks with a changed column config
+ // push boundaries into PageState.constraintBoundaries during layout; without
+ // this step the renderer only sees the page-start column config and would
+ // draw a single full-page separator across regions it no longer applies to.
+ for (const state of states) {
+ const boundaries = state.constraintBoundaries;
+ if (boundaries.length === 0) continue;
+
+ const regions: ColumnRegion[] = [];
+ // First region spans from the top of the content area to the first boundary.
+ // Its columns come from page.columns (set at page creation before any
+ // mid-page region change) or fall back to a single-column default so the
+ // contract stays self-describing even when the page starts single-column.
+ const firstRegionColumns: ColumnLayout = state.page.columns ?? { count: 1, gap: 0 };
+ regions.push({
+ yStart: state.topMargin,
+ yEnd: boundaries[0].y,
+ columns: firstRegionColumns,
+ });
+ for (let i = 0; i < boundaries.length; i++) {
+ const start = boundaries[i];
+ const end = boundaries[i + 1];
+ regions.push({
+ yStart: start.y,
+ yEnd: end ? end.y : state.contentBottom,
+ columns: start.columns,
+ });
+ }
+ state.page.columnRegions = regions;
+ }
+
return {
pageSize,
pages,
@@ -2534,7 +2604,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// after processing sections. Page/region-specific column changes are encoded
// implicitly via fragment positions. Consumers should not assume this is
// a static document-wide value.
- columns: activeColumns.count > 1 ? { count: activeColumns.count, gap: activeColumns.gap } : undefined,
+ columns: activeColumns.count > 1 ? cloneColumnLayout(activeColumns) : undefined,
};
}
@@ -2961,3 +3031,5 @@ export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTok
// Table utilities consumed by layout-bridge and cross-package sync tests
export { getCellLines, getEmbeddedRowLines } from './layout-table.js';
export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js';
+
+export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js';
diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts
index 27a5f7f01e..d70415fad2 100644
--- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts
+++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts
@@ -62,6 +62,7 @@ describe('layoutDrawingBlock', () => {
constraintBoundaries: [];
activeConstraintIndex: number;
trailingSpacing: number;
+ maxCursorY: number;
};
const createMockPageState = (overrides: Record = {}): MockPageState =>
@@ -76,6 +77,7 @@ describe('layoutDrawingBlock', () => {
constraintBoundaries: [],
activeConstraintIndex: -1,
trailingSpacing: 0,
+ maxCursorY: 100,
...overrides,
}) as MockPageState;
diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts
index 12596f4da7..1ec149f646 100644
--- a/packages/layout-engine/layout-engine/src/layout-drawing.ts
+++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts
@@ -140,4 +140,5 @@ export function layoutDrawingBlock({
state.page.fragments.push(fragment);
state.cursorY += requiredHeight;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
}
diff --git a/packages/layout-engine/layout-engine/src/layout-image.ts b/packages/layout-engine/layout-engine/src/layout-image.ts
index a1cfdc651e..ee14cae6b1 100644
--- a/packages/layout-engine/layout-engine/src/layout-image.ts
+++ b/packages/layout-engine/layout-engine/src/layout-image.ts
@@ -86,4 +86,5 @@ export function layoutImageBlock({
state.page.fragments.push(fragment);
state.cursorY += requiredHeight;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
}
diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts
index ade89230e7..39220fe3cb 100644
--- a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts
+++ b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts
@@ -60,6 +60,7 @@ const makePageState = (): PageState => ({
trailingSpacing: 0,
lastParagraphStyleId: undefined,
lastParagraphContextualSpacing: false,
+ maxCursorY: 50,
});
/**
@@ -1271,6 +1272,7 @@ describe('layoutParagraphBlock - keepLines', () => {
currentState = {
...state,
cursorY: 50, // Reset to top of new page
+ maxCursorY: 50,
page: { number: state.page.number + 1, fragments: [] },
trailingSpacing: 0,
};
@@ -1420,6 +1422,7 @@ describe('layoutParagraphBlock - keepLines', () => {
const advanceColumn = mock((state: PageState) => ({
...state,
cursorY: 50,
+ maxCursorY: 50,
trailingSpacing: 0,
page: { number: 2, fragments: [] },
}));
diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts
index 598f1503c7..5bbce31c03 100644
--- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts
+++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts
@@ -789,6 +789,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
if (neededSpacingBefore > 0) {
state.cursorY += neededSpacingBefore;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
if (spacingDebugEnabled) {
spacingDebugLog('spacingBefore applied', {
blockId: block.id,
@@ -907,6 +908,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
state.page.fragments.push(fragment);
state.cursorY += borderExpansion.top + fragmentHeight + borderExpansion.bottom;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
lastState = state;
fromLine = slice.toLine;
}
@@ -929,6 +931,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
appliedSpacingAfter = 0;
} else {
targetState.cursorY += spacingAfter;
+ targetState.maxCursorY = Math.max(targetState.maxCursorY, targetState.cursorY);
}
targetState.trailingSpacing = appliedSpacingAfter;
if (spacingDebugEnabled) {
diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts
index 7f37f86d40..101de88516 100644
--- a/packages/layout-engine/layout-engine/src/layout-table.ts
+++ b/packages/layout-engine/layout-engine/src/layout-table.ts
@@ -1239,6 +1239,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void {
applyTableFragmentPmRange(fragment, context.block, context.measure);
state.page.fragments.push(fragment);
state.cursorY += height;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
}
/**
@@ -1389,6 +1390,7 @@ export function layoutTableBlock({
applyTableFragmentPmRange(fragment, block, measure);
state.page.fragments.push(fragment);
state.cursorY += height;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
return;
}
@@ -1554,6 +1556,7 @@ export function layoutTableBlock({
applyTableFragmentPmRange(fragment, block, measure);
state.page.fragments.push(fragment);
state.cursorY += fragmentHeight;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
}
const rowComplete = !hasRemainingLinesAfterContinuation;
@@ -1668,6 +1671,7 @@ export function layoutTableBlock({
applyTableFragmentPmRange(fragment, block, measure);
state.page.fragments.push(fragment);
state.cursorY += fragmentHeight;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
pendingPartialRow = forcedPartialRow;
samePagePartialContinuation = true;
isTableContinuation = true;
@@ -1717,6 +1721,7 @@ export function layoutTableBlock({
applyTableFragmentPmRange(fragment, block, measure);
state.page.fragments.push(fragment);
state.cursorY += fragmentHeight;
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
// Handle partial row tracking
if (partialRow && !partialRow.isLastPart) {
diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts
index a87d43b73c..f09597f3ad 100644
--- a/packages/layout-engine/layout-engine/src/paginator.ts
+++ b/packages/layout-engine/layout-engine/src/paginator.ts
@@ -20,6 +20,10 @@ export type PageState = {
lastParagraphContextualSpacing: boolean;
/** Border hash of the last paragraph for between-border group detection. */
lastParagraphBorderHash?: string;
+ /** Tracks the maximum cursorY reached across all columns on this page.
+ * Used when starting a mid-page region so the new section begins below
+ * all column content, not just the current column's cursor. */
+ maxCursorY: number;
};
export type PaginatorOptions = {
@@ -107,6 +111,7 @@ export function createPaginator(opts: PaginatorOptions) {
trailingSpacing: 0,
lastParagraphStyleId: undefined,
lastParagraphContextualSpacing: false,
+ maxCursorY: topMargin,
};
states.push(state);
pages.push(state.page);
@@ -123,6 +128,8 @@ export function createPaginator(opts: PaginatorOptions) {
const advanceColumn = (state: PageState): PageState => {
const activeCols = getActiveColumnsForState(state);
if (state.columnIndex < activeCols.count - 1) {
+ // Snapshot max Y before resetting cursor for the next column
+ state.maxCursorY = Math.max(state.maxCursorY, state.cursorY);
state.columnIndex += 1;
if (state.activeConstraintIndex >= 0 && state.constraintBoundaries[state.activeConstraintIndex]) {
state.cursorY = state.constraintBoundaries[state.activeConstraintIndex].y;
diff --git a/packages/layout-engine/layout-engine/src/section-breaks.d.ts b/packages/layout-engine/layout-engine/src/section-breaks.d.ts
index a4fec8ae80..310d6e4690 100644
--- a/packages/layout-engine/layout-engine/src/section-breaks.d.ts
+++ b/packages/layout-engine/layout-engine/src/section-breaks.d.ts
@@ -1,4 +1,5 @@
-import type { SectionBreakBlock } from '@superdoc/contracts';
+import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts';
+
export type SectionState = {
activeTopMargin: number;
activeBottomMargin: number;
@@ -20,14 +21,8 @@ export type SectionState = {
w: number;
h: number;
} | null;
- activeColumns: {
- count: number;
- gap: number;
- };
- pendingColumns: {
- count: number;
- gap: number;
- } | null;
+ activeColumns: ColumnLayout;
+ pendingColumns: ColumnLayout | null;
activeOrientation: 'portrait' | 'landscape' | null;
pendingOrientation: 'portrait' | 'landscape' | null;
hasAnyPages: boolean;
@@ -37,6 +32,7 @@ export type BreakDecision = {
forceMidPageRegion: boolean;
requiredParity?: 'even' | 'odd';
};
+
/**
* Schedule section break effects by updating pending/active state and returning a break decision.
* This function is pure with respect to inputs/outputs and does not mutate external variables.
@@ -56,6 +52,7 @@ export declare function scheduleSectionBreak(
decision: BreakDecision;
state: SectionState;
};
+
/**
* Apply pending margins/pageSize/columns/orientation to active values at a page boundary and clear pending.
*/
diff --git a/packages/layout-engine/layout-engine/src/section-breaks.test.ts b/packages/layout-engine/layout-engine/src/section-breaks.test.ts
index 209c601ecf..49f3143b1f 100644
--- a/packages/layout-engine/layout-engine/src/section-breaks.test.ts
+++ b/packages/layout-engine/layout-engine/src/section-breaks.test.ts
@@ -140,6 +140,44 @@ describe('scheduleSectionBreak', () => {
expect(result.decision.forceMidPageRegion).toBe(false);
expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48 });
});
+
+ it('detects column change when only withSeparator toggles on', () => {
+ const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } });
+ const block = createSectionBreak({
+ type: 'continuous',
+ columns: { count: 2, gap: 48, withSeparator: true },
+ });
+
+ const result = scheduleSectionBreak(block, state, BASE_MARGINS);
+
+ expect(result.decision.forceMidPageRegion).toBe(true);
+ expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48, withSeparator: true });
+ });
+
+ it('detects column change when only withSeparator toggles off', () => {
+ const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: true } });
+ const block = createSectionBreak({
+ type: 'continuous',
+ columns: { count: 2, gap: 48, withSeparator: false },
+ });
+
+ const result = scheduleSectionBreak(block, state, BASE_MARGINS);
+
+ expect(result.decision.forceMidPageRegion).toBe(true);
+ expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48, withSeparator: false });
+ });
+
+ it('does not trigger mid-page region change when undefined and defined false match', () => {
+ const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } });
+ const block = createSectionBreak({
+ type: 'continuous',
+ columns: { count: 2, gap: 48 },
+ });
+
+ const result = scheduleSectionBreak(block, state, BASE_MARGINS);
+
+ expect(result.decision.forceMidPageRegion).toBe(false);
+ });
});
describe('first section handling', () => {
diff --git a/packages/layout-engine/layout-engine/src/section-breaks.ts b/packages/layout-engine/layout-engine/src/section-breaks.ts
index 6eb911657f..3fce475a66 100644
--- a/packages/layout-engine/layout-engine/src/section-breaks.ts
+++ b/packages/layout-engine/layout-engine/src/section-breaks.ts
@@ -30,7 +30,7 @@ export type BreakDecision = {
};
/** Default single-column configuration per OOXML spec (absence of w:cols element) */
-const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 };
+export const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 };
/**
* Get the column configuration for a section break.
@@ -38,7 +38,7 @@ const SINGLE_COLUMN_DEFAULT: Readonly = { count: 1, gap: 0 };
* Per OOXML spec, absence of element means single column layout.
*
* @param blockColumns - The columns property from the section break block (may be undefined)
- * @returns Column configuration with count and gap
+ * @returns Column configuration with count, gap, and separator presence
*/
function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout {
return blockColumns ? cloneColumnLayout(blockColumns) : { ...SINGLE_COLUMN_DEFAULT };
@@ -56,17 +56,22 @@ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout {
*/
function isColumnConfigChanging(blockColumns: ColumnLayout | undefined, activeColumns: ColumnLayout): boolean {
if (blockColumns) {
- // Explicit column change
+ // Explicit column change: any of count, gap, separator presence, equalWidth,
+ // or widths differs. withSeparator must be included because a sep-only toggle
+ // still needs a new column region so the renderer can draw (or stop drawing)
+ // the separator from the toggle point onward.
return (
blockColumns.count !== activeColumns.count ||
blockColumns.gap !== activeColumns.gap ||
+ Boolean(blockColumns.withSeparator) !== Boolean(activeColumns.withSeparator) ||
blockColumns.equalWidth !== activeColumns.equalWidth ||
!widthsEqual(blockColumns.widths, activeColumns.widths)
);
}
- // No columns specified = reset to single column (OOXML default)
- // This is a change only if currently in multi-column layout
- return activeColumns.count > 1;
+ // No columns specified = reset to single column (OOXML default).
+ // This is a change if currently in multi-column layout, or if the separator was on
+ // (the reset implicitly turns it off).
+ return activeColumns.count > 1 || Boolean(activeColumns.withSeparator);
}
/**
diff --git a/packages/layout-engine/layout-engine/src/section-props.test.ts b/packages/layout-engine/layout-engine/src/section-props.test.ts
index 49dbfd4f30..4b65588b3d 100644
--- a/packages/layout-engine/layout-engine/src/section-props.test.ts
+++ b/packages/layout-engine/layout-engine/src/section-props.test.ts
@@ -61,4 +61,53 @@ describe('computeNextSectionPropsAtBreak', () => {
expect(snapshot?.columns).toEqual({ count: 2, gap: 48 });
expect(snapshot?.columns).not.toBe(sourceColumns);
});
+
+ it('propagates withSeparator flag through section property snapshots', () => {
+ const sourceColumns = { count: 2, gap: 48, withSeparator: true };
+ const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })];
+ const map = computeNextSectionPropsAtBreak(blocks);
+ const snapshot = map.get(0);
+
+ expect(snapshot?.columns).toEqual({ count: 2, gap: 48, withSeparator: true });
+ expect(snapshot?.columns).not.toBe(sourceColumns);
+ });
+
+ it('omits withSeparator from the snapshot when not set on source block', () => {
+ const sourceColumns = { count: 2, gap: 48 };
+ const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })];
+ const map = computeNextSectionPropsAtBreak(blocks);
+ const snapshot = map.get(0);
+
+ expect(snapshot?.columns).toEqual({ count: 2, gap: 48 });
+ expect(snapshot?.columns?.withSeparator).toBeUndefined();
+ });
+
+ it('propagates withSeparator from the next section in lookahead', () => {
+ const blocks: FlowBlock[] = [
+ sectionBreak({ id: 'sb-0', columns: { count: 1, gap: 0 } }),
+ { kind: 'paragraph', id: 'p-1', runs: [] } as FlowBlock,
+ sectionBreak({ id: 'sb-2', columns: { count: 2, gap: 48, withSeparator: true } }),
+ ];
+ const map = computeNextSectionPropsAtBreak(blocks);
+
+ expect(map.get(0)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true });
+ expect(map.get(2)?.columns).toEqual({ count: 2, gap: 48, withSeparator: true });
+ });
+
+ it('preserves explicit column widths and equalWidth in snapshots', () => {
+ const sourceColumns = { count: 2, gap: 48, widths: [120, 360], equalWidth: false, withSeparator: true };
+ const blocks: FlowBlock[] = [sectionBreak({ id: 'sb-0', columns: sourceColumns })];
+ const map = computeNextSectionPropsAtBreak(blocks);
+ const snapshot = map.get(0);
+
+ expect(snapshot?.columns).toEqual({
+ count: 2,
+ gap: 48,
+ widths: [120, 360],
+ equalWidth: false,
+ withSeparator: true,
+ });
+ expect(snapshot?.columns).not.toBe(sourceColumns);
+ expect(snapshot?.columns?.widths).not.toBe(sourceColumns.widths);
+ });
});
diff --git a/packages/layout-engine/layout-engine/src/section-props.ts b/packages/layout-engine/layout-engine/src/section-props.ts
index 6c8c6c84c3..e43bc07f5c 100644
--- a/packages/layout-engine/layout-engine/src/section-props.ts
+++ b/packages/layout-engine/layout-engine/src/section-props.ts
@@ -1,4 +1,5 @@
-import type { FlowBlock, SectionVerticalAlign } from '@superdoc/contracts';
+import type { ColumnLayout, FlowBlock, SectionVerticalAlign } from '@superdoc/contracts';
+import { cloneColumnLayout } from './column-utils.js';
/**
* Section-level formatting properties that control page layout.
@@ -16,11 +17,16 @@ import type { FlowBlock, SectionVerticalAlign } from '@superdoc/contracts';
export type SectionProps = {
margins?: { header?: number; footer?: number; top?: number; right?: number; bottom?: number; left?: number };
pageSize?: { w: number; h: number };
- columns?: { count: number; gap: number };
+ columns?: ColumnLayout;
orientation?: 'portrait' | 'landscape';
vAlign?: SectionVerticalAlign;
};
+const snapshotColumns = (columns?: ColumnLayout): ColumnLayout | undefined => {
+ if (!columns) return undefined;
+ return cloneColumnLayout(columns);
+};
+
/**
* Extracts section properties from a section break block if any are present.
* Returns null if the block has no section-related properties.
@@ -59,7 +65,7 @@ const _snapshotSectionProps = (block: FlowBlock): SectionProps | null => {
}
if (block.columns) {
hasProps = true;
- props.columns = { count: block.columns.count, gap: block.columns.gap };
+ props.columns = snapshotColumns(block.columns);
}
if (block.orientation) {
hasProps = true;
@@ -135,7 +141,7 @@ export function computeNextSectionPropsAtBreak(blocks: FlowBlock[]): Map {
}
});
+ it('aligns trailing tabs to explicit right stops with dot leaders (TOC regression)', async () => {
+ const rightStopTwips = 10593;
+ const rightStopPx = rightStopTwips * (96 / 1440); // ~706px
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'toc-paragraph',
+ runs: [
+ { text: '1.', fontFamily: 'Arial', fontSize: 13.333 },
+ { kind: 'tab', text: '\t', tabIndex: 0, pmStart: 2, pmEnd: 3 },
+ { text: 'Generalities', fontFamily: 'Arial', fontSize: 13.333 },
+ { kind: 'tab', text: '\t', tabIndex: 1, pmStart: 15, pmEnd: 16 },
+ { text: '5', fontFamily: 'Arial', fontSize: 13.333 },
+ ],
+ attrs: {
+ indent: { left: 30, right: 0, firstLine: 0, hanging: 30 },
+ tabs: [{ val: 'end', leader: 'dot', pos: rightStopTwips }],
+ },
+ };
+
+ const measure = expectParagraphMeasure(await measureBlock(block, 800));
+ expect(measure.lines).toHaveLength(1);
+ const line = measure.lines[0];
+ expect(line.leaders).toBeDefined();
+ expect(line.leaders?.[0]?.style).toBe('dot');
+ // Leader must end right before the page number — within ~20px of the right stop
+ // (page number "5" is a few px wide, not 100+ px wide).
+ expect(line.leaders?.[0]?.to).toBeLessThanOrEqual(rightStopPx);
+ expect(line.leaders?.[0]?.to).toBeGreaterThan(rightStopPx - 20);
+ // Leader must start AFTER the title text, not at "1." — proves the first tab
+ // fell on the default 0.5" grid, not on the end stop.
+ expect(line.leaders?.[0]?.from).toBeGreaterThan(100);
+ const trailingTab = block.runs[3];
+ if (trailingTab.kind === 'tab') {
+ expect(trailingTab.width).toBeGreaterThan(50);
+ }
+ });
+
+ it('maps three trailing tabs to two explicit alignment stops (asymmetric case)', async () => {
+ const centerStopTwips = 5000;
+ const endStopTwips = 10000;
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'asymmetric-tabs',
+ runs: [
+ { text: 'A', fontFamily: 'Arial', fontSize: 13.333 },
+ { kind: 'tab', text: '\t', tabIndex: 0, pmStart: 1, pmEnd: 2 },
+ { text: 'B', fontFamily: 'Arial', fontSize: 13.333 },
+ { kind: 'tab', text: '\t', tabIndex: 1, pmStart: 3, pmEnd: 4 },
+ { text: 'C', fontFamily: 'Arial', fontSize: 13.333 },
+ { kind: 'tab', text: '\t', tabIndex: 2, pmStart: 5, pmEnd: 6 },
+ { text: 'D', fontFamily: 'Arial', fontSize: 13.333 },
+ ],
+ attrs: {
+ indent: { left: 0, right: 0, firstLine: 0, hanging: 0 },
+ tabs: [
+ { val: 'center', pos: centerStopTwips },
+ { val: 'end', pos: endStopTwips },
+ ],
+ },
+ };
+
+ const measure = expectParagraphMeasure(await measureBlock(block, 800));
+ expect(measure.lines).toHaveLength(1);
+ // Three tabs, two alignment stops: last two tabs bind to center + end.
+ // The first tab must NOT bind to either alignment stop — it should fall on the
+ // default grid. The last tab ends near the end stop position.
+ const lineWidth = measure.lines[0].width;
+ const endStopPx = endStopTwips * (96 / 1440);
+ expect(lineWidth).toBeCloseTo(endStopPx, 0);
+ });
+
it('handles multiple tabs in a row', async () => {
const block: FlowBlock = {
kind: 'paragraph',
@@ -1881,6 +1952,91 @@ describe('measureBlock', () => {
}
}
});
+
+ it('uses surrounding text font size for tab line height, not hardcoded 12', async () => {
+ // Regression: tab runs previously hardcoded maxFontSize=12, producing
+ // wrong line heights when the surrounding text used a larger font.
+ const largeFontBlock: FlowBlock = {
+ kind: 'paragraph',
+ id: 'tab-font-size-large',
+ runs: [
+ { text: 'Hello', fontFamily: 'Arial', fontSize: 24 },
+ { kind: 'tab', text: '\t', pmStart: 5, pmEnd: 6 },
+ { text: 'World', fontFamily: 'Arial', fontSize: 24 },
+ ],
+ attrs: {},
+ };
+
+ const smallFontBlock: FlowBlock = {
+ kind: 'paragraph',
+ id: 'tab-font-size-small',
+ runs: [
+ { text: 'Hello', fontFamily: 'Arial', fontSize: 10 },
+ { kind: 'tab', text: '\t', pmStart: 5, pmEnd: 6 },
+ { text: 'World', fontFamily: 'Arial', fontSize: 10 },
+ ],
+ attrs: {},
+ };
+
+ const largeMeasure = expectParagraphMeasure(await measureBlock(largeFontBlock, 1000));
+ const smallMeasure = expectParagraphMeasure(await measureBlock(smallFontBlock, 1000));
+
+ expect(largeMeasure.lines).toHaveLength(1);
+ expect(smallMeasure.lines).toHaveLength(1);
+
+ // The large-font paragraph must have a taller line than the small-font one.
+ // With the old hardcoded 12, both could collapse to similar heights.
+ expect(largeMeasure.lines[0].lineHeight).toBeGreaterThan(smallMeasure.lines[0].lineHeight);
+ });
+
+ it('uses fallback font size when tab is the first run (no preceding text)', async () => {
+ // When a tab starts a paragraph, lastFontSize should fall back to the
+ // first text run's font size, not a hardcoded default.
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'tab-first-run',
+ runs: [
+ { kind: 'tab', text: '\t', pmStart: 0, pmEnd: 1 },
+ { text: 'After tab', fontFamily: 'Arial', fontSize: 20 },
+ ],
+ attrs: {},
+ };
+
+ const refBlock: FlowBlock = {
+ kind: 'paragraph',
+ id: 'no-tab-ref',
+ runs: [{ text: 'After tab', fontFamily: 'Arial', fontSize: 20 }],
+ attrs: {},
+ };
+
+ const measure = expectParagraphMeasure(await measureBlock(block, 1000));
+ const refMeasure = expectParagraphMeasure(await measureBlock(refBlock, 1000));
+
+ expect(measure.lines).toHaveLength(1);
+ // Line height should match or exceed the reference (same font size drives both)
+ expect(measure.lines[0].lineHeight).toBeGreaterThanOrEqual(refMeasure.lines[0].lineHeight);
+ });
+
+ it('tab-only line inherits font size from following text run', async () => {
+ // A line that contains only a tab should derive its height from the
+ // paragraph's font size context, not from a hardcoded 12pt.
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'tab-only-line',
+ runs: [{ kind: 'tab', text: '\t', pmStart: 0, pmEnd: 1 }],
+ attrs: {
+ // paragraph-level font size hint via a nearby run
+ },
+ };
+
+ const measure = expectParagraphMeasure(await measureBlock(block, 1000));
+
+ expect(measure.lines).toHaveLength(1);
+ // With the fallback font size (default 12 when no runs present),
+ // the line should still have a reasonable height
+ expect(measure.lines[0].lineHeight).toBeGreaterThan(0);
+ expect(measure.lines[0].maxFontSize).toBeGreaterThan(0);
+ });
});
describe('space-only runs', () => {
diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts
index ded7439f9e..d756023eea 100644
--- a/packages/layout-engine/measuring/dom/src/index.ts
+++ b/packages/layout-engine/measuring/dom/src/index.ts
@@ -982,6 +982,9 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
block.attrs?.tabs as TabStop[],
block.attrs?.tabIntervalTwips as number | undefined,
);
+ const alignmentTabStopsPx = tabStops
+ .map((stop, index) => ({ stop, index }))
+ .filter(({ stop }) => stop.val === 'end' || stop.val === 'center' || stop.val === 'decimal');
const decimalSeparator = sanitizeDecimalSeparator(block.attrs?.decimalSeparator);
// Extract bar tab stops for paragraph-level rendering (OOXML: bars on all lines)
@@ -1230,7 +1233,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
};
// Expand runs to handle inline newlines as explicit break runs
- const runsToProcess: Run[] = [];
+ let runsToProcess: Run[] = [];
for (const run of normalizedRuns as Run[]) {
if ((run as TextRun).text && typeof (run as TextRun).text === 'string' && (run as TextRun).text.includes('\n')) {
const textRun = run as TextRun;
@@ -1259,6 +1262,58 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
runsToProcess.push(run as Run);
}
}
+ if (runsToProcess.some((run) => isTextRun(run) && typeof run.text === 'string' && run.text.includes('\t'))) {
+ const expandedRuns: Run[] = [];
+ for (const run of runsToProcess) {
+ if (!isTextRun(run) || typeof run.text !== 'string' || !run.text.includes('\t')) {
+ expandedRuns.push(run);
+ continue;
+ }
+ const textRun = run as TextRun;
+ let buffer = '';
+ let cursor = textRun.pmStart ?? 0;
+ const text = textRun.text;
+ for (let i = 0; i < text.length; i += 1) {
+ const char = text[i];
+ if (char === '\t') {
+ if (buffer.length > 0) {
+ expandedRuns.push({
+ ...textRun,
+ text: buffer,
+ pmStart: cursor - buffer.length,
+ pmEnd: cursor,
+ });
+ buffer = '';
+ }
+ const tabRun: TabRun = {
+ kind: 'tab',
+ text: '\t',
+ pmStart: cursor,
+ pmEnd: cursor + 1,
+ tabStops: block.attrs?.tabs as TabStop[] | undefined,
+ indent,
+ leader: (textRun as unknown as TabRun)?.leader ?? null,
+ sdt: textRun.sdt,
+ };
+ expandedRuns.push(tabRun);
+ cursor += 1;
+ continue;
+ }
+ buffer += char;
+ cursor += 1;
+ }
+ if (buffer.length > 0) {
+ expandedRuns.push({
+ ...textRun,
+ text: buffer,
+ pmStart: cursor - buffer.length,
+ pmEnd: cursor,
+ });
+ }
+ }
+ runsToProcess = expandedRuns;
+ }
+ const totalTabRuns = runsToProcess.reduce((count, run) => (isTabRun(run) ? count + 1 : count), 0);
/**
* Trims trailing regular spaces from a line when it is finalized.
@@ -1306,7 +1361,23 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
}
};
- // Process each run
+ // Word-compat heuristic (not ECMA-376 17.3.3.32): the last N tab characters in a
+ // paragraph bind to the last N explicit end/center/decimal stops. Needed for TOC
+ // entries where a right-aligned dot-leader stop coexists with default grid stops —
+ // strict greedy next-stop resolution would land the trailing tab on a default stop
+ // instead of the leader stop. Mirrored in layout-bridge/src/remeasure.ts.
+ const getAlignmentStopForOrdinal = (ordinal: number): { stop: TabStopPx; index: number } | null => {
+ if (alignmentTabStopsPx.length === 0 || totalTabRuns === 0 || !Number.isFinite(ordinal)) {
+ return null;
+ }
+ if (ordinal < 0 || ordinal >= totalTabRuns) return null;
+ const remainingTabs = totalTabRuns - ordinal - 1;
+ const targetIndex = alignmentTabStopsPx.length - 1 - remainingTabs;
+ if (targetIndex < 0 || targetIndex >= alignmentTabStopsPx.length) return null;
+ return alignmentTabStopsPx[targetIndex];
+ };
+
+ let sequentialTabIndex = 0;
for (let runIndex = 0; runIndex < runsToProcess.length; runIndex++) {
const run = runsToProcess[runIndex];
@@ -1411,20 +1482,40 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
toRun: runIndex,
toChar: 1,
width: 0,
- maxFontSize: 12, // Default font size for tabs
+ maxFontSize: lastFontSize,
+ maxFontInfo: hasSeenTextRun ? undefined : fallbackFontInfo,
maxWidth: getEffectiveWidth(lines.length === 0 ? initialAvailableWidth : bodyContentWidth),
segments: [],
spaceCount: 0,
};
}
- // Advance to next tab stop using the same logic as inline "\t" handling
+ // Advance to the appropriate tab stop (explicit alignment stops take precedence for trailing tabs)
const originX = currentLine.width;
// Use first-line effective indent (accounts for hanging) on first line, body indent otherwise
const effectiveIndent = lines.length === 0 ? indentLeft + rawFirstLineOffset : indentLeft;
const absCurrentX = currentLine.width + effectiveIndent;
- const { target, nextIndex, stop } = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
- tabStopCursor = nextIndex;
+ let stop: TabStopPx | undefined;
+ let target: number;
+ const resolvedTabIndex =
+ typeof (run as TabRun).tabIndex === 'number' && Number.isFinite((run as TabRun).tabIndex)
+ ? (run as TabRun).tabIndex!
+ : sequentialTabIndex;
+ // Keep the sequential counter in sync with explicit tabIndex values so mixed
+ // inputs (explicit + synthetic TabRuns) don't produce out-of-order ordinals.
+ // Mirrors consumeTabOrdinal() in layout-bridge/src/remeasure.ts.
+ sequentialTabIndex = Math.max(sequentialTabIndex, resolvedTabIndex + 1);
+ const forcedAlignment = getAlignmentStopForOrdinal(resolvedTabIndex);
+ if (forcedAlignment && forcedAlignment.stop.pos > absCurrentX + TAB_EPSILON) {
+ stop = forcedAlignment.stop;
+ target = forcedAlignment.stop.pos;
+ tabStopCursor = forcedAlignment.index + 1;
+ } else {
+ const nextStop = getNextTabStopPx(absCurrentX, tabStops, tabStopCursor);
+ target = nextStop.target;
+ tabStopCursor = nextStop.nextIndex;
+ stop = nextStop.stop;
+ }
const maxAbsWidth = currentLine.maxWidth + effectiveIndent;
const clampedTarget = Math.min(target, maxAbsWidth);
const tabAdvance = Math.max(0, clampedTarget - absCurrentX);
@@ -1432,7 +1523,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
// Persist measured tab width on the TabRun for downstream consumers/tests
(run as TabRun & { width?: number }).width = tabAdvance;
- currentLine.maxFontSize = Math.max(currentLine.maxFontSize, 12);
+ currentLine.maxFontSize = Math.max(currentLine.maxFontSize, lastFontSize);
currentLine.toRun = runIndex;
currentLine.toChar = 1; // tab is a single character
let currentLeader: LeaderDecoration | null = null;
diff --git a/packages/layout-engine/painters/dom/src/features/math/CONTRIBUTING.md b/packages/layout-engine/painters/dom/src/features/math/CONTRIBUTING.md
index 8c002d976d..3764f6da11 100644
--- a/packages/layout-engine/painters/dom/src/features/math/CONTRIBUTING.md
+++ b/packages/layout-engine/painters/dom/src/features/math/CONTRIBUTING.md
@@ -36,7 +36,9 @@ type MathObjectConverter = (
doc: Document, // For creating DOM elements
convertChildren: (children: OmmlJsonNode[]) => DocumentFragment,
// Recursively converts nested OMML content
-) => Element | null;
+) => Node | null; // Return a single Element for one atom, or a
+ // DocumentFragment when your converter produces
+ // multiple sibling elements (see m:r / math-run).
```
`convertChildren` is the important one. Pass it any child elements that contain nested math content (`m:e`, `m:num`, `m:sub`, etc.). It handles everything inside them, including other math objects.
diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/box.ts b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts
new file mode 100644
index 0000000000..45dbf14bda
--- /dev/null
+++ b/packages/layout-engine/painters/dom/src/features/math/converters/box.ts
@@ -0,0 +1,122 @@
+import type { MathObjectConverter } from '../types.js';
+
+const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
+
+/**
+ * Convert m:box (grouping container) to MathML .
+ *
+ * OMML structure:
+ * m:box → m:boxPr (optional), m:e (content)
+ *
+ * MathML output:
+ * content
+ *
+ * Per §22.1.2.13 / §22.1.2.14, m:box can carry boxPr children that affect
+ * layout and spacing — opEmu (operator emulator), noBreak (disallow line
+ * breaks), aln (alignment point), diff (differential spacing), argSz. These
+ * have no clean MathML equivalent and are currently dropped; the box
+ * degrades to a plain that preserves grouping but not the other
+ * semantics. Extend here when any of these need first-class support.
+ *
+ * @spec ECMA-376 §22.1.2.13, §22.1.2.14
+ */
+export const convertBox: MathObjectConverter = (node, doc, convertChildren) => {
+ const elements = node.elements ?? [];
+ const base = elements.find((e) => e.name === 'm:e');
+
+ const mrow = doc.createElementNS(MATHML_NS, 'mrow');
+ mrow.appendChild(convertChildren(base?.elements ?? []));
+
+ return mrow.childNodes.length > 0 ? mrow : null;
+};
+
+/**
+ * Convert m:borderBox (bordered box) to MathML .
+ *
+ * OMML structure:
+ * m:borderBox → m:borderBoxPr (optional: m:hideTop, m:hideBot, m:hideLeft, m:hideRight,
+ * m:strikeBLTR, m:strikeH, m:strikeTLBR, m:strikeV),
+ * m:e (content)
+ *
+ * MathML output:
+ * content
+ *
+ * By default all four borders are shown (notation="box"). Individual borders
+ * can be hidden via m:hide* flags, and diagonal/horizontal/vertical strikes
+ * can be added via m:strike* flags.
+ *
+ * @spec ECMA-376 §22.1.2.11
+ */
+export const convertBorderBox: MathObjectConverter = (node, doc, convertChildren) => {
+ const elements = node.elements ?? [];
+ const props = elements.find((e) => e.name === 'm:borderBoxPr');
+ const base = elements.find((e) => e.name === 'm:e');
+
+ /**
+ * OOXML ST_OnOff (§22.9.2.7): on when the element is present and either
+ * `m:val` is absent (spec default = 1) or equals "1" / "true". "on" is
+ * accepted for leniency — Annex L.6.1.3 uses that form even though the
+ * normative enum is {0, 1, true, false}.
+ * TODO: extract to a shared util when m:acc / m:phant / matrix m:tblLook land.
+ */
+ const isOn = (el?: { attributes?: Record }) => {
+ if (!el) return false;
+ const val = el.attributes?.['m:val'];
+ if (val === undefined) return true;
+ return val === '1' || val === 'true' || val === 'on';
+ };
+
+ const hideTop = props?.elements?.find((e) => e.name === 'm:hideTop');
+ const hideBot = props?.elements?.find((e) => e.name === 'm:hideBot');
+ const hideLeft = props?.elements?.find((e) => e.name === 'm:hideLeft');
+ const hideRight = props?.elements?.find((e) => e.name === 'm:hideRight');
+ const strikeBLTR = props?.elements?.find((e) => e.name === 'm:strikeBLTR');
+ const strikeH = props?.elements?.find((e) => e.name === 'm:strikeH');
+ const strikeTLBR = props?.elements?.find((e) => e.name === 'm:strikeTLBR');
+ const strikeV = props?.elements?.find((e) => e.name === 'm:strikeV');
+
+ const notations: string[] = [];
+
+ const allHidden = isOn(hideTop) && isOn(hideBot) && isOn(hideLeft) && isOn(hideRight);
+
+ if (!allHidden) {
+ if (!isOn(hideTop) && !isOn(hideBot) && !isOn(hideLeft) && !isOn(hideRight)) {
+ notations.push('box');
+ } else {
+ if (!isOn(hideTop)) notations.push('top');
+ if (!isOn(hideBot)) notations.push('bottom');
+ if (!isOn(hideLeft)) notations.push('left');
+ if (!isOn(hideRight)) notations.push('right');
+ }
+ }
+
+ if (isOn(strikeBLTR)) notations.push('updiagonalstrike');
+ if (isOn(strikeH)) notations.push('horizontalstrike');
+ if (isOn(strikeTLBR)) notations.push('downdiagonalstrike');
+ if (isOn(strikeV)) notations.push('verticalstrike');
+
+ const content = convertChildren(base?.elements ?? []);
+
+ // Drop empty wrappers — matches convertBox / convertFunction.
+ if (content.childNodes.length === 0) return null;
+
+ if (notations.length === 0) {
+ const mrow = doc.createElementNS(MATHML_NS, 'mrow');
+ mrow.appendChild(content);
+ return mrow;
+ }
+
+ // Wrap the content in an inner before placing it inside .
+ // MathML Core dropped , so Chrome treats it as unknown and does
+ // not apply row layout — each child would render as its own `block math`
+ // line, stacking vertically. An inner is a MathML Core element, so
+ // the row layout runs on its children and everything stays inline.
+ const innerMrow = doc.createElementNS(MATHML_NS, 'mrow');
+ innerMrow.appendChild(content);
+
+ const menclose = doc.createElementNS(MATHML_NS, 'menclose');
+ menclose.setAttribute('notation', notations.join(' '));
+ menclose.appendChild(innerMrow);
+
+ return menclose;
+};
diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts
index 65d1461f88..6aa879340b 100644
--- a/packages/layout-engine/painters/dom/src/features/math/converters/function.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/converters/function.ts
@@ -1,12 +1,91 @@
import type { MathObjectConverter } from '../types.js';
+import { convertMathRunAsFunctionName } from './math-run.js';
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
const FUNCTION_APPLY_OPERATOR = '\u2061';
+// Boundary elements for the function-name mathvariant walk: every MathML
+// element whose children occupy their own semantic slot (base, subscript,
+// limit, matrix cell, etc.). When m:fName wraps one of these, the slot
+// content carries authored styling per ECMA-376 §22.1.2.111 and must not be
+// overwritten. Anything inside these is skipped.
+const MATH_VARIANT_BOUNDARY_ELEMENTS = new Set([
+ 'munder',
+ 'mover',
+ 'munderover',
+ 'msub',
+ 'msup',
+ 'msubsup',
+ 'mmultiscripts',
+ 'mfrac',
+ 'msqrt',
+ 'mroot',
+ 'mtable',
+ 'mtr',
+ 'mtd',
+]);
+
function forceNormalMathVariant(root: ParentNode): void {
- root.querySelectorAll('mi').forEach((identifier) => {
- identifier.setAttribute('mathvariant', 'normal');
- });
+ // Array.from is required here: HTMLCollection is not iterable under the
+ // default DOM lib (needs `dom.iterable`), so `for…of root.children` fails
+ // type-check.
+ for (const child of Array.from(root.children)) {
+ if (MATH_VARIANT_BOUNDARY_ELEMENTS.has(child.localName)) continue;
+ if (child.localName === 'mi' && !child.hasAttribute('mathvariant')) {
+ child.setAttribute('mathvariant', 'normal');
+ }
+ forceNormalMathVariant(child);
+ }
+}
+
+/**
+ * Structural MathML elements whose FIRST child is the "function-name base"
+ * when nested inside m:fName (e.g. m:limLow → , m:limUpp → ,
+ * m:sSub → , etc.). Word's OMML2MML.XSL keeps the base text whole
+ * (e.g. "lim" as one ) even though it splits regular runs per-character.
+ */
+const BASE_BEARING_ELEMENTS = new Set([
+ 'munder',
+ 'mover',
+ 'munderover',
+ 'msub',
+ 'msup',
+ 'msubsup',
+ 'mmultiscripts', // m:sPre inside m:fName
+]);
+
+/**
+ * After per-character splitting in convertMathRun, the base of a nested
+ * limit/script inside m:fName comes out as multiple single-char siblings
+ * wrapped in an . Word's XSL keeps that base whole — merge the siblings
+ * back into a single if they all share the same (or no) mathvariant.
+ */
+function collapseFunctionNameBases(root: ParentNode): void {
+ for (const child of Array.from(root.children)) {
+ if (BASE_BEARING_ELEMENTS.has(child.localName)) {
+ const base = child.children[0];
+ if (base) {
+ collapseMrowToSingleMi(base);
+ collapseFunctionNameBases(base);
+ }
+ } else {
+ collapseFunctionNameBases(child);
+ }
+ }
+}
+
+function collapseMrowToSingleMi(container: Element): void {
+ const children = Array.from(container.children);
+ if (children.length < 2) return;
+ if (!children.every((c) => c.localName === 'mi')) return;
+ const variant = children[0]!.getAttribute('mathvariant');
+ if (!children.every((c) => c.getAttribute('mathvariant') === variant)) return;
+
+ const merged = container.ownerDocument!.createElementNS(MATHML_NS, 'mi');
+ merged.textContent = children.map((c) => c.textContent ?? '').join('');
+ if (variant) merged.setAttribute('mathvariant', variant);
+ container.insertBefore(merged, children[0]!);
+ for (const c of children) c.remove();
}
/**
@@ -31,7 +110,19 @@ export const convertFunction: MathObjectConverter = (node, doc, convertChildren)
const wrapper = doc.createElementNS(MATHML_NS, 'mrow');
const functionNameRow = doc.createElementNS(MATHML_NS, 'mrow');
- functionNameRow.appendChild(convertChildren(functionName?.elements ?? []));
+ // m:r children of m:fName stay whole (Word's OMML2MML.XSL keeps multi-letter
+ // function names like "sin" or "lim" as a single ). Non-m:r children —
+ // like a nested m:limLow — go through the normal recursive path.
+ for (const child of functionName?.elements ?? []) {
+ if (child.name === 'm:r') {
+ const atom = convertMathRunAsFunctionName(child, doc);
+ if (atom) functionNameRow.appendChild(atom);
+ } else {
+ const converted = convertChildren([child]);
+ if (converted.childNodes.length > 0) functionNameRow.appendChild(converted);
+ }
+ }
+ collapseFunctionNameBases(functionNameRow);
forceNormalMathVariant(functionNameRow);
if (functionNameRow.childNodes.length > 0) {
diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts
index fcc7916fcb..0af6795656 100644
--- a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts
@@ -24,3 +24,4 @@ export { convertNary } from './nary.js';
export { convertPhantom } from './phantom.js';
export { convertGroupCharacter } from './group-character.js';
export { convertMatrix } from './matrix.js';
+export { convertBox, convertBorderBox } from './box.js';
diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts b/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts
index 79fddd2375..7f979563f9 100644
--- a/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts
@@ -46,8 +46,7 @@ const OPERATOR_CHARS = new Set([
'\u220C', // ∈, ∉, ∋, ∌
'\u2211',
'\u220F', // ∑, ∏
- '\u221A',
- '\u221E', // √, ∞
+ '\u221A', // √ (radical sign — prefix operator)
'\u2227',
'\u2228',
'\u2229',
@@ -65,16 +64,70 @@ const OPERATOR_CHARS = new Set([
'\u2287', // ⊂, ⊃, ⊆, ⊇
]);
+type MathAtomTag = 'mi' | 'mo' | 'mn';
+
+function isDigit(ch: string): boolean {
+ return ch >= '0' && ch <= '9';
+}
+
/**
- * Classify a text string into MathML element type.
- * - All-digit strings → (number)
- * - Known operators → (operator)
- * - Everything else → (identifier)
+ * Length in UTF-16 code units of the code point starting at `text[i]`.
+ * Handles surrogate pairs so astral-plane characters (e.g. mathematical
+ * italic U+1D465) don't get split into two bogus atoms.
*/
-function classifyMathText(text: string): 'mn' | 'mo' | 'mi' {
- if (/^\d*\.?\d+$/.test(text)) return 'mn';
- if (text.length === 1 && OPERATOR_CHARS.has(text)) return 'mo';
- return 'mi';
+function codePointUnitLength(text: string, i: number): number {
+ const hi = text.charCodeAt(i);
+ if (hi >= 0xd800 && hi <= 0xdbff && i + 1 < text.length) {
+ const lo = text.charCodeAt(i + 1);
+ if (lo >= 0xdc00 && lo <= 0xdfff) return 2;
+ }
+ return 1;
+}
+
+/**
+ * Split a math run's text into MathML atoms, matching Word's OMML2MML.XSL.
+ *
+ * Rules (ECMA-376 §22.1.2.116 example + Annex L.6.1.13):
+ * - Consecutive digits — optionally containing one decimal point between digits —
+ * group into a single ``.
+ * - Each recognized operator character becomes its own ``.
+ * - Every other character becomes its own ``.
+ *
+ * Example: `"n+1"` → `[n, +, 1]`.
+ */
+export function tokenizeMathText(text: string): Array<{ tag: MathAtomTag; content: string }> {
+ const atoms: Array<{ tag: MathAtomTag; content: string }> = [];
+ let i = 0;
+ while (i < text.length) {
+ const step = codePointUnitLength(text, i);
+ const ch = text.slice(i, i + step);
+ if (step === 1 && isDigit(ch)) {
+ let end = i + 1;
+ let sawDot = false;
+ while (end < text.length) {
+ const c = text[end]!;
+ if (isDigit(c)) {
+ end++;
+ continue;
+ }
+ if (c === '.' && !sawDot && end + 1 < text.length && isDigit(text[end + 1]!)) {
+ sawDot = true;
+ end++;
+ continue;
+ }
+ break;
+ }
+ atoms.push({ tag: 'mn', content: text.slice(i, end) });
+ i = end;
+ } else if (step === 1 && OPERATOR_CHARS.has(ch)) {
+ atoms.push({ tag: 'mo', content: ch });
+ i++;
+ } else {
+ atoms.push({ tag: 'mi', content: ch });
+ i += step;
+ }
+ }
+ return atoms;
}
/** ECMA-376 m:sty → MathML mathvariant (§22.1.2 math run properties). */
@@ -115,47 +168,140 @@ function resolveMathVariant(rPr: OmmlJsonNode | undefined): string | null {
return null;
}
+function extractText(node: OmmlJsonNode): string {
+ let text = '';
+ for (const child of node.elements ?? []) {
+ if (child.name === 'm:t') {
+ for (const tc of child.elements ?? []) {
+ if (tc.type === 'text' && typeof tc.text === 'string') text += tc.text;
+ }
+ }
+ }
+ return text;
+}
+
/**
- * Convert an m:r (math run) element to MathML.
+ * Convert an m:r (math run) element to MathML atoms.
*
* m:r contains:
* - m:rPr (math run properties: script, style, normal text flag)
* - m:t (text content)
* - Optionally w:rPr (WordprocessingML run properties for formatting)
*
- * The text is classified as , , or based on content.
+ * The run's text is split per-character into `` / `` / `` atoms
+ * per Word's OMML2MML.XSL. For a single-atom run (common case — a one-letter
+ * variable, single operator, or an all-digit number) the converter returns a
+ * single Element. For a multi-atom run (e.g. "→∞", "x+1") it returns a
+ * DocumentFragment whose children become siblings of the parent mrow.
+ *
+ * @spec ECMA-376 §22.1.2.116 (t) — example shows multi-char mixed runs as the
+ * normal authored shape; §22.1.2.58 (lit) implies operators are classified
+ * per-character by default.
*/
export const convertMathRun: MathObjectConverter = (node, doc) => {
- const elements = node.elements ?? [];
+ const text = extractText(node);
+ if (!text) return null;
- // Extract text from m:t children
- let text = '';
- for (const child of elements) {
- if (child.name === 'm:t') {
- const textChildren = child.elements ?? [];
- for (const tc of textChildren) {
- if (tc.type === 'text' && typeof tc.text === 'string') {
- text += tc.text;
+ const rPr = (node.elements ?? []).find((el) => el.name === 'm:rPr');
+ const variant = resolveMathVariant(rPr);
+ const atoms = tokenizeMathText(text);
+
+ const createAtom = (atom: { tag: MathAtomTag; content: string }): Element => {
+ const el = doc.createElementNS(MATHML_NS, atom.tag);
+ el.textContent = atom.content;
+ // Apply m:rPr-derived variant to every atom in the run. Omitted attribute
+ // means "use the MathML default" (italic for single-char , normal
+ // for multi-char //).
+ if (variant) el.setAttribute('mathvariant', variant);
+ return el;
+ };
+
+ if (atoms.length === 1) return createAtom(atoms[0]!);
+
+ const fragment = doc.createDocumentFragment();
+ for (const atom of atoms) fragment.appendChild(createAtom(atom));
+ return fragment;
+};
+
+/**
+ * Tokenize a math run's text for the m:fName context: consecutive non-digit,
+ * non-operator characters stay grouped in one `` (so "log" in "log_2"
+ * remains a single identifier), while digits still group into `` and
+ * each operator character is its own ``.
+ *
+ * Matches Word's OMML2MML.XSL run-internal classification for m:fName
+ * content: `log_2` → `log_2`.
+ */
+function tokenizeFunctionNameText(text: string): Array<{ tag: MathAtomTag; content: string }> {
+ const atoms: Array<{ tag: MathAtomTag; content: string }> = [];
+ let i = 0;
+ while (i < text.length) {
+ const step = codePointUnitLength(text, i);
+ const ch = text.slice(i, i + step);
+ if (step === 1 && isDigit(ch)) {
+ let end = i + 1;
+ let sawDot = false;
+ while (end < text.length) {
+ const c = text[end]!;
+ if (isDigit(c)) {
+ end++;
+ continue;
+ }
+ if (c === '.' && !sawDot && end + 1 < text.length && isDigit(text[end + 1]!)) {
+ sawDot = true;
+ end++;
+ continue;
}
+ break;
}
+ atoms.push({ tag: 'mn', content: text.slice(i, end) });
+ i = end;
+ } else if (step === 1 && OPERATOR_CHARS.has(ch)) {
+ atoms.push({ tag: 'mo', content: ch });
+ i++;
+ } else {
+ // Group consecutive non-digit, non-operator code points into one .
+ let end = i + step;
+ while (end < text.length) {
+ const s = codePointUnitLength(text, end);
+ const c = text.slice(end, end + s);
+ if (s === 1 && (isDigit(c) || OPERATOR_CHARS.has(c))) break;
+ end += s;
+ }
+ atoms.push({ tag: 'mi', content: text.slice(i, end) });
+ i = end;
}
}
+ return atoms;
+}
+/**
+ * Convert an m:r inside m:fName (m:func's function-name slot). Word's
+ * OMML2MML.XSL keeps each letter-sequence whole while still splitting out
+ * digits and operators — so `sin` stays `sin`, but `log_2` becomes
+ * `log_2`.
+ *
+ * Returns a single Element for single-atom runs or a DocumentFragment when
+ * the run emits multiple atoms. Returns null for empty text.
+ */
+export function convertMathRunAsFunctionName(node: OmmlJsonNode, doc: Document): Node | null {
+ const text = extractText(node);
if (!text) return null;
- const rPr = elements.find((el) => el.name === 'm:rPr');
+ const rPr = (node.elements ?? []).find((el) => el.name === 'm:rPr');
const variant = resolveMathVariant(rPr);
- const tag = classifyMathText(text);
+ const atoms = tokenizeFunctionNameText(text);
- const el = doc.createElementNS(MATHML_NS, tag);
- el.textContent = text;
+ const createAtom = (atom: { tag: MathAtomTag; content: string }): Element => {
+ const el = doc.createElementNS(MATHML_NS, atom.tag);
+ el.textContent = atom.content;
+ if (variant) el.setAttribute('mathvariant', variant);
+ return el;
+ };
- // Apply mathvariant when the spec properties resolve to one. The default
- // for single-char is italic and for multi-char // is
- // normal — we only set an attribute when m:rPr explicitly specifies it.
- if (variant) {
- el.setAttribute('mathvariant', variant);
- }
+ if (atoms.length === 1) return createAtom(atoms[0]!);
- return el;
-};
+ const fragment = doc.createDocumentFragment();
+ for (const atom of atoms) fragment.appendChild(createAtom(atom));
+ return fragment;
+}
diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
index c5a1345725..32930a01eb 100644
--- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { JSDOM } from 'jsdom';
import { convertOmmlToMathml, MATHML_NS } from './omml-to-mathml.js';
+import { tokenizeMathText } from './converters/math-run.js';
const dom = new JSDOM('');
const doc = dom.window.document;
@@ -343,6 +344,310 @@ describe('convertOmmlToMathml', () => {
expect(children.some((c) => c.localName === 'mo')).toBe(true); // +
expect(children.some((c) => c.localName === 'mn')).toBe(true); // 1
});
+
+ // ─── tokenizeMathText direct coverage (SD-2632) ────────────────────────────
+
+ it('tokenizes leading-decimal content with . as an operator followed by digits', () => {
+ // ".5" has no leading digit, so the "." is not part of a number.
+ expect(tokenizeMathText('.5')).toEqual([
+ { tag: 'mo', content: '.' },
+ { tag: 'mn', content: '5' },
+ ]);
+ });
+
+ it('tokenizes a trailing decimal point as a separate operator', () => {
+ // "5." — the digit run ends at "5" because a lookahead digit is required.
+ expect(tokenizeMathText('5.')).toEqual([
+ { tag: 'mn', content: '5' },
+ { tag: 'mo', content: '.' },
+ ]);
+ });
+
+ it('tokenizes "1.2.3" as number, operator, number — only first dot is inline', () => {
+ expect(tokenizeMathText('1.2.3')).toEqual([
+ { tag: 'mn', content: '1.2' },
+ { tag: 'mo', content: '.' },
+ { tag: 'mn', content: '3' },
+ ]);
+ });
+
+ it('tokenizes "2x+1" — number-identifier-operator-number', () => {
+ expect(tokenizeMathText('2x+1')).toEqual([
+ { tag: 'mn', content: '2' },
+ { tag: 'mi', content: 'x' },
+ { tag: 'mo', content: '+' },
+ { tag: 'mn', content: '1' },
+ ]);
+ });
+
+ it('tokenizes consecutive operator characters as separate atoms', () => {
+ expect(tokenizeMathText('\u2264\u2265')).toEqual([
+ { tag: 'mo', content: '\u2264' },
+ { tag: 'mo', content: '\u2265' },
+ ]);
+ });
+
+ it('tokenizes empty text as an empty list', () => {
+ expect(tokenizeMathText('')).toEqual([]);
+ });
+
+ it('tokenizes standalone ∞ as identifier, not operator (SD-2632)', () => {
+ // U+221E was removed from OPERATOR_CHARS; Word classifies it as .
+ expect(tokenizeMathText('\u221E')).toEqual([{ tag: 'mi', content: '\u221E' }]);
+ });
+
+ it('keeps astral-plane characters whole (does not split surrogate pairs)', () => {
+ // 𝑥 (U+1D465, mathematical italic small x) is a UTF-16 surrogate pair.
+ // Splitting by code unit would emit two bogus half-pair s.
+ const text = '\u{1D465}+1';
+ expect(tokenizeMathText(text)).toEqual([
+ { tag: 'mi', content: '\u{1D465}' },
+ { tag: 'mo', content: '+' },
+ { tag: 'mn', content: '1' },
+ ]);
+ });
+
+ // ─── SD-2632: per-character split of multi-char m:r text ──────────────────
+
+ it('splits a single m:r containing operator + identifier into + (SD-2632)', () => {
+ // Fixture case 1 of math-limit-tests.docx has m:r "→∞" as one run inside
+ // m:limLow's m:lim. Word's OMML2MML.XSL splits it to →∞.
+ const omml = {
+ name: 'm:oMath',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u2192\u221E' }] }] }],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const children = Array.from(result!.children);
+ expect(children.map((c) => `${c.localName}:${c.textContent}`)).toEqual(['mo:\u2192', 'mi:\u221E']);
+ });
+
+ it('splits "x+1=2" per character with digits grouped (SD-2632)', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x+1=2' }] }] }],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const children = Array.from(result!.children);
+ expect(children.map((c) => `${c.localName}:${c.textContent}`)).toEqual(['mi:x', 'mo:+', 'mn:1', 'mo:=', 'mn:2']);
+ });
+
+ it('groups consecutive digits with an interior decimal point into one (SD-2632)', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '123.45+67' }] }] }],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const children = Array.from(result!.children);
+ expect(children.map((c) => `${c.localName}:${c.textContent}`)).toEqual(['mn:123.45', 'mo:+', 'mn:67']);
+ });
+
+ it('splits m:r content inside m:sub of an m:sSub (SD-2632 F3)', () => {
+ // Word's built-up "b_(n+1)" has "n+1" as a single m:r inside m:sub.
+ // The subscript should contain separate n+1.
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:sSub',
+ elements: [
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }],
+ },
+ {
+ name: 'm:sub',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n+1' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const subMrow = result!.querySelector('msub > mrow:nth-child(2)');
+ expect(subMrow).not.toBeNull();
+ const children = Array.from(subMrow!.children);
+ expect(children.map((c) => `${c.localName}:${c.textContent}`)).toEqual(['mi:n', 'mo:+', 'mn:1']);
+ });
+
+ it('preserves m:rPr mathvariant across every atom of a split run (SD-2632)', () => {
+ // When m:sty="b" (bold) applies to the whole run, every atom emitted
+ // from the split inherits it.
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:r',
+ elements: [
+ { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'b' } }] },
+ { name: 'm:t', elements: [{ type: 'text', text: 'x+1' }] },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const variants = Array.from(result!.children).map((c) => c.getAttribute('mathvariant'));
+ expect(variants).toEqual(['bold', 'bold', 'bold']);
+ });
+
+ it('keeps "log" whole but splits operators and digits for m:fName with mixed content (SD-2632)', () => {
+ // Word's OMML2MML.XSL for log_2: letters group
+ // into one , operators and digits still split.
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:func',
+ elements: [
+ {
+ name: 'm:fName',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'log_2' }] }] }],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const fnameRow = result!.querySelector('mrow > mrow:first-child');
+ const children = Array.from(fnameRow!.children);
+ expect(children.map((c) => `${c.localName}:${c.textContent}`)).toEqual(['mi:log', 'mo:_', 'mn:2']);
+ });
+
+ it('collapses a multi-char base inside nested m:sSub wrapped by m:fName (SD-2632)', () => {
+ // Ensures the msub/msup entries of BASE_BEARING_ELEMENTS are actually pinned.
+ // fi should
+ // keep "f" as a single inside the subscript wrapper's base slot.
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:func',
+ elements: [
+ {
+ name: 'm:fName',
+ elements: [
+ {
+ name: 'm:sSub',
+ elements: [
+ {
+ name: 'm:e',
+ elements: [
+ {
+ name: 'm:r',
+ elements: [
+ { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'p' } }] },
+ { name: 'm:t', elements: [{ type: 'text', text: 'log' }] },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'm:sub',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const msub = result!.querySelector('msub');
+ expect(msub).not.toBeNull();
+ const baseMi = msub!.children[0]!.querySelector('mi');
+ expect(baseMi!.textContent).toBe('log');
+ expect(baseMi!.getAttribute('mathvariant')).toBe('normal');
+ });
+
+ it('collapses multi-char base inside nested m:sPre (mmultiscripts) wrapped by m:fName (SD-2632)', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:func',
+ elements: [
+ {
+ name: 'm:fName',
+ elements: [
+ {
+ name: 'm:sPre',
+ elements: [
+ {
+ name: 'm:sub',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '2' }] }] }],
+ },
+ {
+ name: 'm:sup',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }],
+ },
+ {
+ name: 'm:e',
+ elements: [
+ {
+ name: 'm:r',
+ elements: [
+ { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'p' } }] },
+ { name: 'm:t', elements: [{ type: 'text', text: 'log' }] },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const mms = result!.querySelector('mmultiscripts');
+ expect(mms).not.toBeNull();
+ const baseMi = mms!.children[0]!.querySelector('mi');
+ expect(baseMi!.textContent).toBe('log');
+ expect(baseMi!.getAttribute('mathvariant')).toBe('normal');
+ });
+
+ it('keeps multi-letter function names whole inside m:func > m:fName (SD-2632 exception)', () => {
+ // Word's OMML2MML.XSL keeps "sin" as one when nested in m:fName,
+ // even though it would otherwise per-char split a bare m:r. Exception is
+ // applied by convertFunction.
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:func',
+ elements: [
+ {
+ name: 'm:fName',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const functionName = result!.querySelector('mrow > mrow:first-child > mi');
+ expect(functionName!.textContent).toBe('sin');
+ expect(functionName!.getAttribute('mathvariant')).toBe('normal');
+ });
});
describe('m:bar converter', () => {
@@ -861,6 +1166,43 @@ describe('m:func converter', () => {
expect(mis[0]!.textContent).toBe('sin');
expect(mis[1]!.textContent).toBe('cos');
});
+
+ it('preserves explicit m:sty=i on function-name runs', () => {
+ // SD-2538 preserve branch: forceNormalMathVariant must NOT overwrite
+ // an existing mathvariant. When Word marks a function-name run with
+ // m:sty="i", convertMathRun already set mathvariant="italic" — the
+ // function-apply pass must leave it alone.
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:func',
+ elements: [
+ {
+ name: 'm:fName',
+ elements: [
+ {
+ name: 'm:r',
+ elements: [
+ { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'i' } }] },
+ { name: 'm:t', elements: [{ type: 'text', text: 'L' }] },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const nameMi = result!.querySelectorAll('mi')[0];
+ expect(nameMi!.textContent).toBe('L');
+ expect(nameMi!.getAttribute('mathvariant')).toBe('italic');
+ });
});
describe('m:rad converter', () => {
@@ -2296,8 +2638,18 @@ describe('m:limLow converter', () => {
const munder = result!.querySelector('munder');
expect(munder).not.toBeNull();
expect(munder!.children.length).toBe(2);
- expect(munder!.children[0]!.textContent).toBe('lim');
- expect(munder!.children[1]!.textContent).toBe('n');
+
+ // Base: "lim" — upright via m:sty=p on the run.
+ const limMi = munder!.children[0]!.querySelector('mi');
+ expect(limMi!.textContent).toBe('lim');
+ expect(limMi!.getAttribute('mathvariant')).toBe('normal');
+
+ // Limit expression: "n" — must stay italic (no mathvariant attribute set).
+ // SD-2538 regression: convertFunction used to recurse into the
+ // and force mathvariant="normal" on every , including this one.
+ const nMi = munder!.children[1]!.querySelector('mi');
+ expect(nMi!.textContent).toBe('n');
+ expect(nMi!.getAttribute('mathvariant')).toBeNull();
});
});
@@ -2565,8 +2917,17 @@ describe('m:limUpp converter', () => {
const mover = result!.querySelector('mover');
expect(mover).not.toBeNull();
expect(mover!.children.length).toBe(2);
- expect(mover!.children[0]!.textContent).toBe('lim');
- expect(mover!.children[1]!.textContent).toBe('x');
+
+ // Base: "lim" — upright via m:sty=p. Limit variable "x" — italic default.
+ // Symmetric to the m:limLow-in-m:func case; pins the 'mover' entry of
+ // MATH_VARIANT_BOUNDARY_ELEMENTS.
+ const limMi = mover!.children[0]!.querySelector('mi');
+ expect(limMi!.textContent).toBe('lim');
+ expect(limMi!.getAttribute('mathvariant')).toBe('normal');
+
+ const xMi = mover!.children[1]!.querySelector('mi');
+ expect(xMi!.textContent).toBe('x');
+ expect(xMi!.getAttribute('mathvariant')).toBeNull();
});
});
@@ -3929,3 +4290,344 @@ describe('m:m converter', () => {
expect(mtable!.textContent).toBe('ab');
});
});
+describe('m:box converter', () => {
+ it('converts m:box to ', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:box',
+ elements: [
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ expect(result).not.toBeNull();
+ expect(result!.querySelector('mrow')).not.toBeNull();
+ expect(result!.textContent).toBe('x');
+ });
+
+ it('returns null for empty m:box', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [{ name: 'm:box', elements: [] }],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ expect(result).toBeNull();
+ });
+
+ it('drops m:boxPr children (opEmu / noBreak / aln / diff are not yet mapped)', () => {
+ // Pins current scope: we render and silently ignore boxPr semantics.
+ // When opEmu or noBreak grow real MathML mappings, this test should fail
+ // and be updated — that failure is the point.
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:box',
+ elements: [
+ {
+ name: 'm:boxPr',
+ elements: [
+ { name: 'm:opEmu', attributes: { 'm:val': '1' } },
+ { name: 'm:noBreak', attributes: { 'm:val': '1' } },
+ { name: 'm:aln' },
+ { name: 'm:diff', attributes: { 'm:val': '1' } },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '==' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ expect(result).not.toBeNull();
+ expect(result!.querySelector('mrow')).not.toBeNull();
+ expect(result!.querySelector('menclose')).toBeNull();
+ expect(result!.textContent).toBe('==');
+ });
+});
+
+describe('m:borderBox converter', () => {
+ it('converts m:borderBox to by default', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'E' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ expect(result).not.toBeNull();
+ const menclose = result!.querySelector('menclose');
+ expect(menclose).not.toBeNull();
+ expect(menclose!.getAttribute('notation')).toBe('box');
+ expect(menclose!.textContent).toBe('E');
+ });
+
+ it('hides top and bottom sides (notation="left right")', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ {
+ name: 'm:borderBoxPr',
+ elements: [
+ { name: 'm:hideTop', attributes: { 'm:val': '1' } },
+ { name: 'm:hideBot', attributes: { 'm:val': '1' } },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const menclose = result!.querySelector('menclose');
+ expect(menclose).not.toBeNull();
+ // Exact string — production order is top/bottom/left/right, so a side-swap regression fails here.
+ expect(menclose!.getAttribute('notation')).toBe('left right');
+ });
+
+ it('adds strike notations', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ {
+ name: 'm:borderBoxPr',
+ elements: [
+ { name: 'm:hideTop', attributes: { 'm:val': '1' } },
+ { name: 'm:hideBot', attributes: { 'm:val': '1' } },
+ { name: 'm:hideLeft', attributes: { 'm:val': '1' } },
+ { name: 'm:hideRight', attributes: { 'm:val': '1' } },
+ { name: 'm:strikeH', attributes: { 'm:val': '1' } },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const menclose = result!.querySelector('menclose');
+ expect(menclose).not.toBeNull();
+ expect(menclose!.getAttribute('notation')).toBe('horizontalstrike');
+ });
+
+ it('falls back to when all borders hidden and no strikes', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ {
+ name: 'm:borderBoxPr',
+ elements: [
+ { name: 'm:hideTop', attributes: { 'm:val': '1' } },
+ { name: 'm:hideBot', attributes: { 'm:val': '1' } },
+ { name: 'm:hideLeft', attributes: { 'm:val': '1' } },
+ { name: 'm:hideRight', attributes: { 'm:val': '1' } },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'q' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ const menclose = result!.querySelector('menclose');
+ expect(menclose).toBeNull();
+ expect(result!.textContent).toBe('q');
+ });
+
+ // ── ST_OnOff variants (ECMA-376 §22.9.2.7) ────────────────────────────────
+ // isOn accepts "1", "true", "on", and bare tags; rejects "0" / "false" / "off".
+ // Annex L.6.1.3 itself uses m:val="on" even though the normative enum is {0,1,true,false}.
+
+ const makeBorderBox = (hideTopFlag: Record) => ({
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ { name: 'm:borderBoxPr', elements: [{ name: 'm:hideTop', ...hideTopFlag }] },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
+ },
+ ],
+ },
+ ],
+ });
+
+ it('treats m:val="true" as on (ST_OnOff)', () => {
+ const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'true' } }), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right');
+ });
+
+ it('treats m:val="on" as on (Annex L.6.1.3 form)', () => {
+ const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'on' } }), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right');
+ });
+
+ it('treats bare as on (spec default val=1)', () => {
+ const result = convertOmmlToMathml(makeBorderBox({}), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right');
+ });
+
+ it('treats m:val="0" as off (top remains visible)', () => {
+ const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': '0' } }), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box');
+ });
+
+ it('treats m:val="false" as off', () => {
+ const result = convertOmmlToMathml(makeBorderBox({ attributes: { 'm:val': 'false' } }), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('box');
+ });
+
+ // ── Strike directions ─────────────────────────────────────────────────────
+ // BLTR (bottom-left → top-right = "/") maps to updiagonalstrike.
+ // TLBR (top-left → bottom-right = "\") maps to downdiagonalstrike.
+ // The directional naming is counter-intuitive — these tests pin it.
+
+ const makeStrike = (strikeName: string) => ({
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ {
+ name: 'm:borderBoxPr',
+ elements: [
+ { name: 'm:hideTop', attributes: { 'm:val': '1' } },
+ { name: 'm:hideBot', attributes: { 'm:val': '1' } },
+ { name: 'm:hideLeft', attributes: { 'm:val': '1' } },
+ { name: 'm:hideRight', attributes: { 'm:val': '1' } },
+ { name: strikeName, attributes: { 'm:val': '1' } },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
+ },
+ ],
+ },
+ ],
+ });
+
+ it('maps m:strikeBLTR to notation="updiagonalstrike" (/ direction)', () => {
+ const result = convertOmmlToMathml(makeStrike('m:strikeBLTR'), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('updiagonalstrike');
+ });
+
+ it('maps m:strikeTLBR to notation="downdiagonalstrike" (\\ direction)', () => {
+ const result = convertOmmlToMathml(makeStrike('m:strikeTLBR'), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('downdiagonalstrike');
+ });
+
+ it('maps m:strikeV to notation="verticalstrike"', () => {
+ const result = convertOmmlToMathml(makeStrike('m:strikeV'), doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('verticalstrike');
+ });
+
+ it('combines multiple strikes in a fixed order', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ {
+ name: 'm:borderBoxPr',
+ elements: [
+ { name: 'm:strikeBLTR', attributes: { 'm:val': '1' } },
+ { name: 'm:strikeH', attributes: { 'm:val': '1' } },
+ { name: 'm:strikeTLBR', attributes: { 'm:val': '1' } },
+ { name: 'm:strikeV', attributes: { 'm:val': '1' } },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe(
+ 'box updiagonalstrike horizontalstrike downdiagonalstrike verticalstrike',
+ );
+ });
+
+ it('combines partial hide flags with a strike (hideTop + strikeH)', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [
+ {
+ name: 'm:borderBoxPr',
+ elements: [
+ { name: 'm:hideTop', attributes: { 'm:val': '1' } },
+ { name: 'm:strikeH', attributes: { 'm:val': '1' } },
+ ],
+ },
+ {
+ name: 'm:e',
+ elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }],
+ },
+ ],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ expect(result!.querySelector('menclose')!.getAttribute('notation')).toBe('bottom left right horizontalstrike');
+ });
+
+ it('returns null when m:e is empty (no bordered-but-empty )', () => {
+ const omml = {
+ name: 'm:oMath',
+ elements: [
+ {
+ name: 'm:borderBox',
+ elements: [{ name: 'm:borderBoxPr', elements: [{ name: 'm:strikeH', attributes: { 'm:val': '1' } }] }],
+ },
+ ],
+ };
+ const result = convertOmmlToMathml(omml, doc);
+ // oMath still renders but has no children because borderBox dropped itself.
+ expect(result).toBeNull();
+ });
+});
diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts
index e2f8f016f9..2f49e3d5f7 100644
--- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts
@@ -29,6 +29,8 @@ import {
convertPhantom,
convertGroupCharacter,
convertMatrix,
+ convertBox,
+ convertBorderBox,
} from './converters/index.js';
export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
@@ -66,9 +68,8 @@ const MATH_OBJECT_REGISTRY: Record = {
'm:sSubSup': convertSubSuperscript, // Sub-superscript (both)
'm:sPre': convertPreSubSuperscript, // Pre-sub-superscript (left of base)
- // ── Not yet implemented (community contributions welcome) ────────────────
- 'm:borderBox': null, // Border box (border around math content)
- 'm:box': null, // Box (invisible grouping container)
+ 'm:borderBox': convertBorderBox, // Border box (border around math content)
+ 'm:box': convertBox, // Box (invisible grouping container)
'm:groupChr': convertGroupCharacter, // Group character (overbrace, underbrace)
};
diff --git a/packages/layout-engine/painters/dom/src/features/math/types.ts b/packages/layout-engine/painters/dom/src/features/math/types.ts
index f1e8d90b71..00c31ea60f 100644
--- a/packages/layout-engine/painters/dom/src/features/math/types.ts
+++ b/packages/layout-engine/painters/dom/src/features/math/types.ts
@@ -43,4 +43,4 @@ export type MathObjectConverter = (
node: OmmlJsonNode,
doc: Document,
convertChildren: (children: OmmlJsonNode[]) => DocumentFragment,
-) => Element | null;
+) => Node | null;
diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index 2ba2389f57..3a1e8f57bf 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -1787,6 +1787,130 @@ describe('DomPainter', () => {
const emptySpan = mount.querySelector('.superdoc-line span.superdoc-empty-run') as HTMLElement | null;
expect(emptySpan?.dataset.pmStart).toBe('1');
expect(emptySpan?.dataset.pmEnd).toBe('1');
+ // Empty-run must set explicit fontSize so it doesn't inherit fontSize:0 from the line
+ expect(emptySpan?.style.fontSize).toBe('18px');
+ });
+
+ it('sets fallback fontSize on field annotation without explicit fontSize', () => {
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'fa-no-fontsize',
+ runs: [
+ {
+ kind: 'fieldAnnotation',
+ variant: 'text',
+ displayLabel: 'Client Name',
+ fieldId: 'F1',
+ fieldType: 'text',
+ fieldColor: '#980043',
+ pmStart: 0,
+ pmEnd: 1,
+ },
+ ],
+ };
+ const measure: Measure = {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ };
+ const testLayout: Layout = {
+ pageSize: layout.pageSize,
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'fa-no-fontsize', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }],
+ },
+ ],
+ };
+ const painter = createTestPainter({ blocks: [block], measures: [measure] });
+ painter.paint(testLayout, mount);
+
+ const annotation = mount.querySelector('.annotation') as HTMLElement | null;
+ // Must always set fontSize so it doesn't inherit fontSize:0 from the line.
+ // Falls back to 16px (browser default) when run has no explicit fontSize.
+ expect(annotation?.style.fontSize).toBe('16px');
+ });
+
+ it('converts numeric fontSize to pt on field annotation', () => {
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'fa-numeric-fontsize',
+ runs: [
+ {
+ kind: 'fieldAnnotation',
+ variant: 'text',
+ displayLabel: 'Client Name',
+ fieldId: 'F1',
+ fieldType: 'text',
+ fieldColor: '#980043',
+ fontSize: 14,
+ pmStart: 0,
+ pmEnd: 1,
+ },
+ ],
+ };
+ const measure: Measure = {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 12, descent: 4, lineHeight: 20 }],
+ totalHeight: 20,
+ };
+ const testLayout: Layout = {
+ pageSize: layout.pageSize,
+ pages: [
+ {
+ number: 1,
+ fragments: [
+ { kind: 'para', blockId: 'fa-numeric-fontsize', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 },
+ ],
+ },
+ ],
+ };
+ const painter = createTestPainter({ blocks: [block], measures: [measure] });
+ painter.paint(testLayout, mount);
+
+ const annotation = mount.querySelector('.annotation') as HTMLElement | null;
+ // Numeric fontSize is converted to pt units.
+ expect(annotation?.style.fontSize).toBe('14pt');
+ });
+
+ it('sets explicit fontSize on math run wrapper', () => {
+ const block: FlowBlock = {
+ kind: 'paragraph',
+ id: 'math-block',
+ runs: [
+ {
+ kind: 'math',
+ ommlJson: {},
+ textContent: 'x+1',
+ width: 40,
+ height: 14,
+ pmStart: 0,
+ pmEnd: 1,
+ },
+ ],
+ };
+ const measure: Measure = {
+ kind: 'paragraph',
+ lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 40, ascent: 10, descent: 4, lineHeight: 18 }],
+ totalHeight: 18,
+ };
+ const testLayout: Layout = {
+ pageSize: layout.pageSize,
+ pages: [
+ {
+ number: 1,
+ fragments: [{ kind: 'para', blockId: 'math-block', fromLine: 0, toLine: 1, x: 10, y: 10, width: 200 }],
+ },
+ ],
+ };
+ const painter = createTestPainter({ blocks: [block], measures: [measure] });
+ painter.paint(testLayout, mount);
+
+ const mathWrapper = mount.querySelector('.sd-math') as HTMLElement | null;
+ // Must set fontSize so fallback text doesn't inherit fontSize:0 from the line.
+ // Uses browser default (16px) rather than run.height, which would render tall
+ // expressions at 80–100px for the plain-text fallback path.
+ expect(mathWrapper?.style.fontSize).toBe('16px');
});
it('renders image fragments', () => {
diff --git a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts
new file mode 100644
index 0000000000..6b8c2e27e2
--- /dev/null
+++ b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts
@@ -0,0 +1,247 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { createDomPainter } from './index.js';
+import type { ColumnRegion, Layout, Page } from '@superdoc/contracts';
+
+// These tests pin down DomPainter's column-separator rendering:
+// - the fallback path (page.columns only, no mid-page regions)
+// - the region-aware path (page.columnRegions supersedes page.columns)
+// - all five early-return guards inside renderColumnSeparators
+// The layout-engine tests cover which data reaches the painter; these tests
+// cover what the painter does with it.
+
+const buildPage = (overrides: Partial = {}): Page => ({
+ number: 1,
+ fragments: [],
+ margins: { top: 96, right: 96, bottom: 96, left: 96 },
+ ...overrides,
+});
+
+const buildLayout = (page: Page, pageSize = { w: 816, h: 1056 }): Layout => ({
+ pageSize,
+ pages: [page],
+});
+
+const querySeparators = (mount: HTMLElement): HTMLDivElement[] => {
+ // Separators are the only 1px-wide absolutely-positioned divs added to a page.
+ // Scoping by the inline styles keeps this brittle-free against unrelated
+ // absolute-positioned overlays (rulers, selection, floats).
+ return Array.from(mount.querySelectorAll('div')).filter((el) => {
+ const s = el.style;
+ return s.position === 'absolute' && s.width === '1px' && s.backgroundColor === '#000000';
+ }) as HTMLDivElement[];
+};
+
+const paintOnce = (layout: Layout, mount: HTMLElement): void => {
+ const painter = createDomPainter({ blocks: [], measures: [] });
+ painter.paint(layout, mount);
+};
+
+describe('DomPainter renderColumnSeparators', () => {
+ let mount: HTMLElement;
+
+ beforeEach(() => {
+ mount = document.createElement('div');
+ document.body.appendChild(mount);
+ });
+
+ afterEach(() => {
+ mount.remove();
+ });
+
+ describe('fallback path (page.columns only)', () => {
+ it('draws a single separator centered in the gap for 2 equal columns', () => {
+ const page = buildPage({ columns: { count: 2, gap: 48, withSeparator: true } });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(1);
+ // pageWidth=816, margins=96 → contentWidth=624, columnWidth=(624-48)/2=288.
+ // separator x = leftMargin + columnWidth + gap/2 = 96 + 288 + 24 = 408.
+ expect(seps[0].style.left).toBe('408px');
+ expect(seps[0].style.top).toBe('96px');
+ // height = pageHeight - top - bottom = 1056 - 96 - 96 = 864.
+ expect(seps[0].style.height).toBe('864px');
+ });
+
+ it('draws count-1 separators for 3 equal columns', () => {
+ const page = buildPage({ columns: { count: 3, gap: 48, withSeparator: true } });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(2);
+ // columnWidth = (624 - 48*2) / 3 = 176.
+ // sep 0: 96 + 176 + 48/2 = 296. sep 1: 96 + 2*176 + 48 + 48/2 = 520.
+ expect(seps.map((s) => s.style.left)).toEqual(['296px', '520px']);
+ });
+
+ it('uses explicit column widths when drawing separators for page.columns', () => {
+ const page = buildPage({
+ columns: { count: 2, gap: 48, widths: [200, 952], equalWidth: false, withSeparator: true },
+ });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(1);
+ // contentWidth=624, availableWidth=576. Explicit widths [200, 952] are
+ // normalized to [100, 476], so the separator belongs at 96 + 100 + 24 = 220.
+ expect(seps[0].style.left).toBe('220px');
+ });
+
+ it('renders nothing when withSeparator is false', () => {
+ const page = buildPage({ columns: { count: 2, gap: 48, withSeparator: false } });
+ paintOnce(buildLayout(page), mount);
+
+ expect(querySeparators(mount)).toHaveLength(0);
+ });
+
+ it('renders nothing when withSeparator is omitted (undefined)', () => {
+ const page = buildPage({ columns: { count: 2, gap: 48 } });
+ paintOnce(buildLayout(page), mount);
+
+ expect(querySeparators(mount)).toHaveLength(0);
+ });
+
+ it('renders nothing for single-column pages', () => {
+ const page = buildPage({ columns: { count: 1, gap: 0, withSeparator: true } });
+ paintOnce(buildLayout(page), mount);
+
+ expect(querySeparators(mount)).toHaveLength(0);
+ });
+
+ it('renders nothing when page has neither columns nor columnRegions', () => {
+ paintOnce(buildLayout(buildPage()), mount);
+ expect(querySeparators(mount)).toHaveLength(0);
+ });
+
+ it('renders nothing when page.margins is missing', () => {
+ const page: Page = {
+ number: 1,
+ fragments: [],
+ columns: { count: 2, gap: 48, withSeparator: true },
+ };
+ paintOnce(buildLayout(page), mount);
+ expect(querySeparators(mount)).toHaveLength(0);
+ });
+
+ it('renders nothing when columnWidth collapses to <=1px', () => {
+ // Pathological case: tiny page with a huge gap leaves no room for columns.
+ const page = buildPage({
+ margins: { top: 10, right: 10, bottom: 10, left: 10 },
+ columns: { count: 2, gap: 100, withSeparator: true },
+ });
+ paintOnce(buildLayout(page, { w: 110, h: 200 }), mount);
+ // contentWidth=90, columnWidth=(90-100)/2=-5 → guard fires.
+ expect(querySeparators(mount)).toHaveLength(0);
+ });
+ });
+
+ describe('region-aware path (page.columnRegions)', () => {
+ it('draws per-region separators bounded by each region yStart/yEnd', () => {
+ const regions: ColumnRegion[] = [
+ { yStart: 96, yEnd: 400, columns: { count: 2, gap: 48, withSeparator: true } },
+ { yStart: 400, yEnd: 700, columns: { count: 3, gap: 48, withSeparator: true } },
+ ];
+ // page.columns is set to the first region's config (matches what the
+ // layout engine does); the renderer must prefer columnRegions.
+ const page = buildPage({
+ columns: regions[0].columns,
+ columnRegions: regions,
+ });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ // Region 0: 1 separator for 2-col. Region 1: 2 separators for 3-col.
+ expect(seps).toHaveLength(3);
+
+ // Region 0 bounds.
+ expect(seps[0].style.top).toBe('96px');
+ expect(seps[0].style.height).toBe('304px'); // 400 - 96
+ expect(seps[0].style.left).toBe('408px');
+
+ // Region 1 bounds.
+ expect(seps[1].style.top).toBe('400px');
+ expect(seps[1].style.height).toBe('300px'); // 700 - 400
+ expect(seps[2].style.top).toBe('400px');
+ expect(seps[2].style.height).toBe('300px');
+ // 3-col positions computed fresh for region 1: 296px and 520px.
+ expect([seps[1].style.left, seps[2].style.left]).toEqual(['296px', '520px']);
+ });
+
+ it('skips regions whose withSeparator is false even if other regions render', () => {
+ const regions: ColumnRegion[] = [
+ { yStart: 96, yEnd: 400, columns: { count: 2, gap: 48, withSeparator: true } },
+ { yStart: 400, yEnd: 700, columns: { count: 2, gap: 48, withSeparator: false } },
+ { yStart: 700, yEnd: 960, columns: { count: 2, gap: 48, withSeparator: true } },
+ ];
+ const page = buildPage({ columnRegions: regions });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(2);
+ // Only regions 0 and 2 produce output.
+ expect(seps.map((s) => s.style.top)).toEqual(['96px', '700px']);
+ expect(seps.map((s) => s.style.height)).toEqual(['304px', '260px']);
+ });
+
+ it('skips single-column regions', () => {
+ const regions: ColumnRegion[] = [
+ { yStart: 96, yEnd: 400, columns: { count: 1, gap: 0, withSeparator: true } },
+ { yStart: 400, yEnd: 700, columns: { count: 2, gap: 48, withSeparator: true } },
+ ];
+ const page = buildPage({ columnRegions: regions });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(1);
+ expect(seps[0].style.top).toBe('400px');
+ });
+
+ it('skips regions with non-positive height', () => {
+ const regions: ColumnRegion[] = [
+ { yStart: 96, yEnd: 96, columns: { count: 2, gap: 48, withSeparator: true } },
+ { yStart: 96, yEnd: 500, columns: { count: 2, gap: 48, withSeparator: true } },
+ ];
+ const page = buildPage({ columnRegions: regions });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(1);
+ expect(seps[0].style.height).toBe('404px');
+ });
+
+ it('prefers columnRegions over page.columns when both are present', () => {
+ // page.columns says "no separator", but columnRegions says "draw one".
+ // The regions should win — they represent the authoritative per-region
+ // state, page.columns only represents the page-start config.
+ const page = buildPage({
+ columns: { count: 2, gap: 48, withSeparator: false },
+ columnRegions: [{ yStart: 96, yEnd: 960, columns: { count: 2, gap: 48, withSeparator: true } }],
+ });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(1);
+ expect(seps[0].style.top).toBe('96px');
+ expect(seps[0].style.height).toBe('864px');
+ });
+
+ it('uses explicit column widths when drawing separators for columnRegions', () => {
+ const page = buildPage({
+ columnRegions: [
+ {
+ yStart: 96,
+ yEnd: 500,
+ columns: { count: 2, gap: 48, widths: [200, 952], equalWidth: false, withSeparator: true },
+ },
+ ],
+ });
+ paintOnce(buildLayout(page), mount);
+
+ const seps = querySeparators(mount);
+ expect(seps).toHaveLength(1);
+ expect(seps[0].style.top).toBe('96px');
+ expect(seps[0].style.height).toBe('404px');
+ expect(seps[0].style.left).toBe('220px');
+ });
+ });
+});
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 28115ea837..ce6869b26d 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -1,5 +1,6 @@
import type {
ChartDrawing,
+ ColumnLayout,
CustomGeometryData,
DrawingBlock,
DrawingFragment,
@@ -61,6 +62,7 @@ import {
calculateJustifySpacing,
computeLinePmRange,
getCellSpacingPx,
+ normalizeColumnLayout,
normalizeBaselineShift,
resolveBaseFontSizeForVerticalText,
shouldApplyJustify,
@@ -84,12 +86,14 @@ import {
import { assertFragmentPmPositions, assertPmPositions } from './pm-position-validation.js';
import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js';
import {
+ BROWSER_DEFAULT_FONT_SIZE,
CLASS_NAMES,
containerStyles,
containerStylesHorizontal,
ensureFieldAnnotationStyles,
ensureImageSelectionStyles,
ensureLinkStyles,
+ ensureMathMencloseStyles,
ensurePrintStyles,
ensureSdtContainerStyles,
ensureTrackChangeStyles,
@@ -1674,6 +1678,7 @@ export class DomPainter {
ensureFieldAnnotationStyles(doc);
ensureSdtContainerStyles(doc);
ensureImageSelectionStyles(doc);
+ ensureMathMencloseStyles(doc);
if (!this.isSemanticFlow && this.options.ruler?.enabled) {
ensureRulerStyles(doc);
}
@@ -2230,6 +2235,8 @@ export class DomPainter {
);
});
this.renderDecorationsForPage(el, page, pageIndex);
+ this.renderColumnSeparators(el, page, width, height);
+
return el;
}
@@ -2310,6 +2317,92 @@ export class DomPainter {
}
}
+ private renderColumnSeparators(pageEl: HTMLElement, page: Page, pageWidth: number, pageHeight: number): void {
+ if (!this.doc) return;
+ if (!page.margins) return;
+
+ const leftMargin = page.margins.left ?? 0;
+ const rightMargin = page.margins.right ?? 0;
+ const topMargin = page.margins.top ?? 0;
+ const bottomMargin = page.margins.bottom ?? 0;
+ const contentWidth = pageWidth - leftMargin - rightMargin;
+
+ // Prefer columnRegions (per-region configs for pages with continuous
+ // section breaks that change column layout mid-page). Fall back to a
+ // single region derived from page.columns so pages without mid-page
+ // changes keep working unchanged.
+ const regions =
+ page.columnRegions ??
+ (page.columns
+ ? [
+ {
+ yStart: topMargin,
+ yEnd: pageHeight - bottomMargin,
+ columns: page.columns,
+ },
+ ]
+ : []);
+
+ for (const region of regions) {
+ const { columns, yStart, yEnd } = region;
+ if (!columns.withSeparator) continue;
+ if (columns.count <= 1) continue;
+
+ const regionHeight = yEnd - yStart;
+ if (regionHeight <= 0) continue;
+
+ const separatorPositions = this.getColumnSeparatorPositions(columns, leftMargin, contentWidth);
+ if (separatorPositions.length === 0) continue;
+
+ for (const separatorX of separatorPositions) {
+ const separatorEl = this.doc.createElement('div');
+
+ separatorEl.style.position = 'absolute';
+ separatorEl.style.left = `${separatorX}px`;
+ separatorEl.style.top = `${yStart}px`;
+ separatorEl.style.height = `${regionHeight}px`;
+ separatorEl.style.width = '1px';
+ separatorEl.style.backgroundColor = '#000000';
+ separatorEl.style.pointerEvents = 'none';
+ pageEl.appendChild(separatorEl);
+ }
+ }
+ }
+
+ private getColumnSeparatorPositions(columns: ColumnLayout, leftMargin: number, contentWidth: number): number[] {
+ const hasExplicitWidths = Array.isArray(columns.widths) && columns.widths.length > 0;
+
+ if (!hasExplicitWidths) {
+ const equalWidth = (contentWidth - columns.gap * (columns.count - 1)) / columns.count;
+ if (equalWidth <= 1) return [];
+
+ const separatorPositions: number[] = [];
+ for (let index = 0; index < columns.count - 1; index += 1) {
+ separatorPositions.push(leftMargin + (index + 1) * equalWidth + index * columns.gap + columns.gap / 2);
+ }
+ return separatorPositions;
+ }
+
+ const normalizedColumns = normalizeColumnLayout(columns, contentWidth);
+ if (normalizedColumns.count <= 1) return [];
+
+ const columnWidths =
+ normalizedColumns.widths ?? Array.from({ length: normalizedColumns.count }, () => normalizedColumns.width);
+ // A 1px separator only makes sense when every participating column is wider than the separator itself.
+ if (columnWidths.some((columnWidth) => columnWidth <= 1)) return [];
+
+ const separatorPositions: number[] = [];
+ let cursorX = leftMargin;
+
+ for (let index = 0; index < normalizedColumns.count - 1; index += 1) {
+ const currentColumnWidth = columnWidths[index] ?? normalizedColumns.width;
+ separatorPositions.push(cursorX + currentColumnWidth + normalizedColumns.gap / 2);
+ cursorX += currentColumnWidth + normalizedColumns.gap;
+ }
+
+ return separatorPositions;
+ }
+
private renderDecorationsForPage(pageEl: HTMLElement, page: Page, pageIndex: number): void {
if (this.isSemanticFlow) return;
this.renderDecorationSection(pageEl, page, pageIndex, 'header');
@@ -2816,6 +2909,8 @@ export class DomPainter {
});
this.renderDecorationsForPage(el, page, pageIndex);
+ this.renderColumnSeparators(el, page, pageSize.w, pageSize.h);
+
return { element: el, fragments: fragmentStates };
}
@@ -5170,6 +5265,12 @@ export class DomPainter {
// Let browser auto-size to MathML content; estimated dimensions are for layout only
wrapper.style.minWidth = `${run.width}px`;
wrapper.style.minHeight = `${run.height}px`;
+ // Restore font-size so the plain-text fallback renders at a reasonable size
+ // (the line container sets fontSize: 0 to eliminate the CSS strut). MathML
+ // has its own internal scaling, so this only matters for the textContent
+ // fallback path. run.height would make tall expressions (fractions, equation
+ // arrays) render at 80–100px — use the browser default instead.
+ wrapper.style.fontSize = BROWSER_DEFAULT_FONT_SIZE;
wrapper.dataset.layoutEpoch = String(this.layoutEpoch ?? 0);
const mathEl = convertOmmlToMathml(run.ommlJson, this.doc);
@@ -5442,6 +5543,7 @@ export class DomPainter {
// Position and z-index on the image only (not the line) so resize overlay can stack above.
img.style.position = 'relative';
img.style.zIndex = '1';
+ img.style.maxWidth = '100%';
}
// Apply rotation and flip transforms from OOXML a:xfrm
@@ -5666,12 +5768,20 @@ export class DomPainter {
}
}
- // Apply typography to the annotation element
+ // Apply typography to the annotation element.
+ // Always set a font-size so the annotation never inherits fontSize: 0 from
+ // the line container (which zeroes it to eliminate the CSS strut). When the
+ // run has no explicit fontSize, fall back to BROWSER_DEFAULT_FONT_SIZE (the
+ // browser default that was previously inherited before the strut fix).
if (run.fontFamily) {
annotation.style.fontFamily = run.fontFamily;
}
- if (run.fontSize) {
- const fontSize = typeof run.fontSize === 'number' ? `${run.fontSize}pt` : run.fontSize;
+ {
+ const fontSize = run.fontSize
+ ? typeof run.fontSize === 'number'
+ ? `${run.fontSize}pt`
+ : run.fontSize
+ : BROWSER_DEFAULT_FONT_SIZE;
annotation.style.fontSize = fontSize;
}
if (run.textColor) {
@@ -5875,6 +5985,9 @@ export class DomPainter {
if (lineRange.pmEnd != null) {
span.dataset.pmEnd = String(lineRange.pmEnd);
}
+ // Restore font-size so the remains a visible caret target
+ // (the line container sets fontSize: 0 to eliminate the CSS strut).
+ span.style.fontSize = `${line.lineHeight}px`;
span.innerHTML = ' ';
el.appendChild(span);
}
diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts
index d1170bf9f4..bcdd59e772 100644
--- a/packages/layout-engine/painters/dom/src/styles.test.ts
+++ b/packages/layout-engine/painters/dom/src/styles.test.ts
@@ -1,5 +1,18 @@
import { describe, expect, it } from 'vitest';
-import { ensureSdtContainerStyles } from './styles.js';
+import { ensureSdtContainerStyles, lineStyles } from './styles.js';
+
+describe('lineStyles', () => {
+ it('sets height and lineHeight from the argument', () => {
+ const styles = lineStyles(24);
+ expect(styles.height).toBe('24px');
+ expect(styles.lineHeight).toBe('24px');
+ });
+
+ it('sets fontSize to 0 to eliminate the CSS strut', () => {
+ const styles = lineStyles(20);
+ expect(styles.fontSize).toBe('0');
+ });
+});
describe('ensureSdtContainerStyles', () => {
it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => {
diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts
index 2f8cb1e498..b548d37107 100644
--- a/packages/layout-engine/painters/dom/src/styles.ts
+++ b/packages/layout-engine/painters/dom/src/styles.ts
@@ -1,5 +1,12 @@
import { DOM_CLASS_NAMES } from './constants.js';
+/**
+ * Fallback font-size applied to child elements inside a line container that
+ * carry no explicit fontSize. Matches the browser default so rendering is
+ * preserved after the strut-elimination fix (fontSize: '0' on lines).
+ */
+export const BROWSER_DEFAULT_FONT_SIZE = '16px';
+
export const CLASS_NAMES = {
container: 'superdoc-layout',
page: 'superdoc-page',
@@ -90,6 +97,15 @@ export const fragmentStyles: Partial = {
export const lineStyles = (lineHeight: number): Partial => ({
lineHeight: `${lineHeight}px`,
height: `${lineHeight}px`,
+ // Eliminate the CSS "strut" created by the inherited font-size (typically
+ // the browser default 16px). Without this, the strut shifts normal-flow
+ // inline children down via baseline alignment, while absolutely-positioned
+ // children (used for tab-aligned segments) are unaffected — causing
+ // tab-indented first lines to appear shifted up relative to continuation
+ // lines. All text-bearing child elements set their own explicit font-size;
+ // elements that don't (empty-run, math wrapper, field annotation wrapper)
+ // are patched individually in renderer.ts.
+ fontSize: '0',
position: 'relative',
display: 'block',
whiteSpace: 'pre',
@@ -630,12 +646,85 @@ const IMAGE_SELECTION_STYLES = `
}
`;
+const MATH_MENCLOSE_STYLES = `
+/* MathML polyfill.
+ *
+ * MathML 3 defined with borders, strikes, and other
+ * enclosure notations. MathML Core (the subset shipped in Chrome 109+, 2023)
+ * dropped — the WG moved its rendering to CSS/SVG. Firefox and
+ * WebKit also do not paint it. Without this polyfill, m:borderBox content
+ * imports correctly (the notation attribute is right) but renders invisibly.
+ *
+ * Each notation token is composable: "box horizontalstrike" draws the box
+ * border and a horizontal strike together. Diagonal strikes layer through
+ * CSS custom properties so X patterns (both diagonals) stack correctly.
+ *
+ * @spec MathML 3 §3.3.8 menclose
+ */
+menclose {
+ display: inline-block;
+ position: relative;
+ padding: 0.15em 0.25em;
+
+ --sd-menclose-stroke: currentColor;
+ --sd-menclose-h: none;
+ --sd-menclose-v: none;
+ --sd-menclose-up: none;
+ --sd-menclose-down: none;
+}
+
+menclose[notation~="box"] { border: 1px solid var(--sd-menclose-stroke); }
+menclose[notation~="roundedbox"] { border: 1px solid var(--sd-menclose-stroke); border-radius: 0.3em; }
+menclose[notation~="top"] { border-top: 1px solid var(--sd-menclose-stroke); }
+menclose[notation~="bottom"] { border-bottom: 1px solid var(--sd-menclose-stroke); }
+menclose[notation~="left"] { border-left: 1px solid var(--sd-menclose-stroke); }
+menclose[notation~="right"] { border-right: 1px solid var(--sd-menclose-stroke); }
+
+menclose[notation~="horizontalstrike"] {
+ --sd-menclose-h: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 100% 1px;
+}
+menclose[notation~="verticalstrike"] {
+ --sd-menclose-v: linear-gradient(var(--sd-menclose-stroke), var(--sd-menclose-stroke)) no-repeat center / 1px 100%;
+}
+/* Gradient direction is perpendicular to the stripe it produces.
+ * "to bottom right" → stripe runs bottom-left → top-right (visually "/") = updiagonalstrike.
+ * "to top right" → stripe runs top-left → bottom-right (visually "\") = downdiagonalstrike.
+ */
+menclose[notation~="updiagonalstrike"] {
+ --sd-menclose-up: linear-gradient(
+ to bottom right,
+ transparent calc(50% - 0.5px),
+ var(--sd-menclose-stroke) calc(50% - 0.5px),
+ var(--sd-menclose-stroke) calc(50% + 0.5px),
+ transparent calc(50% + 0.5px)
+ );
+}
+menclose[notation~="downdiagonalstrike"] {
+ --sd-menclose-down: linear-gradient(
+ to top right,
+ transparent calc(50% - 0.5px),
+ var(--sd-menclose-stroke) calc(50% - 0.5px),
+ var(--sd-menclose-stroke) calc(50% + 0.5px),
+ transparent calc(50% + 0.5px)
+ );
+}
+
+menclose::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: var(--sd-menclose-h), var(--sd-menclose-v), var(--sd-menclose-up), var(--sd-menclose-down);
+}
+`;
+
let printStylesInjected = false;
let linkStylesInjected = false;
let trackChangeStylesInjected = false;
let sdtContainerStylesInjected = false;
let fieldAnnotationStylesInjected = false;
let imageSelectionStylesInjected = false;
+let mathMencloseStylesInjected = false;
export const ensurePrintStyles = (doc: Document | null | undefined) => {
if (printStylesInjected || !doc) return;
@@ -696,3 +785,17 @@ export const ensureImageSelectionStyles = (doc: Document | null | undefined) =>
doc.head?.appendChild(styleEl);
imageSelectionStylesInjected = true;
};
+
+/**
+ * Injects the MathML polyfill into the document head. Required
+ * because no browser paints menclose natively (MathML Core dropped it). See
+ * MATH_MENCLOSE_STYLES for the full rationale.
+ */
+export const ensureMathMencloseStyles = (doc: Document | null | undefined) => {
+ if (mathMencloseStylesInjected || !doc) return;
+ const styleEl = doc.createElement('style');
+ styleEl.setAttribute('data-superdoc-math-menclose-styles', 'true');
+ styleEl.textContent = MATH_MENCLOSE_STYLES;
+ doc.head?.appendChild(styleEl);
+ mathMencloseStylesInjected = true;
+};
diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts
index 6836c4319d..b8a44538dd 100644
--- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts
+++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts
@@ -1454,7 +1454,7 @@ describe('renderTableCell', () => {
const lineEl = paraWrapper.firstElementChild as HTMLElement;
const markerEl = lineEl.querySelector('.superdoc-paragraph-marker') as HTMLElement;
- expect(markerEl.style.fontFamily).toBe('"Times New Roman", sans-serif');
+ expect(markerEl.style.fontFamily).toBe('"Times New Roman", serif');
expect(markerEl.style.fontSize).toBe('18px');
expect(markerEl.style.fontWeight).toBe('bold');
expect(markerEl.style.fontStyle).toBe('italic');
diff --git a/packages/layout-engine/pm-adapter/src/attributes/borders.ts b/packages/layout-engine/pm-adapter/src/attributes/borders.ts
index c1a78ba32f..3977700118 100644
--- a/packages/layout-engine/pm-adapter/src/attributes/borders.ts
+++ b/packages/layout-engine/pm-adapter/src/attributes/borders.ts
@@ -324,6 +324,11 @@ export function extractCellPadding(cellAttrs: Record): BoxSpaci
export const normalizeParagraphBorders = (value: unknown): ParagraphAttrs['borders'] | undefined => {
if (!value || typeof value !== 'object') return undefined;
const source = value as Record;
+ // Note: w:bar is intentionally not in this list. We tested in Word and it
+ // never draws w:bar on screen — it just keeps the value in the file when
+ // saving. The spec lets apps skip it, and Word does. SuperDoc does too, by
+ // default. If you have a real document where bar needs to be drawn, open
+ // an issue with the use case before adding 'bar' here.
const sides: Array<'top' | 'right' | 'bottom' | 'left' | 'between'> = ['top', 'right', 'bottom', 'left', 'between'];
const borders: ParagraphAttrs['borders'] = {};
diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts
index c701136ccd..dfa44dbf3c 100644
--- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts
@@ -119,7 +119,7 @@ import {
const DEFAULT_HYPERLINK_CONFIG: HyperlinkConfig = { enableRichHyperlinks: false };
const DEFAULT_TEST_FONT_FAMILY = 'Arial, sans-serif';
const DEFAULT_TEST_FONT_SIZE_PX = (16 * 96) / 72;
-const FALLBACK_FONT_FAMILY = 'Times New Roman, sans-serif';
+const FALLBACK_FONT_FAMILY = 'Times New Roman, serif';
const FALLBACK_FONT_SIZE_PX = 12;
let defaultConverterContext: ConverterContext = {
translatedNumbering: {},
diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts
index 415dbb87b2..4f387dd52b 100644
--- a/packages/layout-engine/pm-adapter/src/index.test.ts
+++ b/packages/layout-engine/pm-adapter/src/index.test.ts
@@ -70,7 +70,7 @@ describe('toFlowBlocks', () => {
runs: [
{
text: 'Hello world',
- fontFamily: 'Times New Roman, sans-serif',
+ fontFamily: 'Times New Roman, serif',
},
],
});
@@ -114,7 +114,7 @@ describe('toFlowBlocks', () => {
});
expect(blocks[0].runs[0]).toMatchObject({
- fontFamily: 'Times New Roman, sans-serif',
+ fontFamily: 'Times New Roman, serif',
});
expect(blocks[0].runs[0]?.fontSize).toBeCloseTo(14, 5);
});
@@ -903,6 +903,7 @@ describe('toFlowBlocks', () => {
expect((contentBreak as FlowBlock).columns).toEqual({
count: 2,
gap: 101.53333333333333,
+ withSeparator: false,
widths: [72, 497.26666666666665],
equalWidth: false,
});
@@ -1077,7 +1078,7 @@ describe('toFlowBlocks', () => {
expect(multiColumnBreak).toBeDefined();
expect((multiColumnBreak as FlowBlock).attrs?.requirePageBoundary).toBeUndefined();
// Gap is in pixels (0.5in = 48px @96DPI)
- expect((multiColumnBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48 });
+ expect((multiColumnBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48, withSeparator: false });
});
it('interprets missing w:num in w:cols as a single-column layout change', () => {
@@ -1110,7 +1111,7 @@ describe('toFlowBlocks', () => {
const allBreaks = getSectionBreaks(blocks, { includeFirst: true });
const tailBreak = allBreaks.find((b) => b.attrs?.sectionIndex === 0);
expect(tailBreak).toBeDefined();
- expect((tailBreak as never).columns).toEqual({ count: 1, gap: 48 });
+ expect((tailBreak as never).columns).toEqual({ count: 1, gap: 48, withSeparator: false });
});
describe('Regression tests for section property bug fixes', () => {
@@ -1158,7 +1159,7 @@ describe('toFlowBlocks', () => {
expect(firstBreak).toBeDefined();
expect(secondBreak).toBeDefined();
// Both have w:space="720" which means single column
- expect((firstBreak as FlowBlock).columns).toEqual({ count: 1, gap: 48 });
+ expect((firstBreak as FlowBlock).columns).toEqual({ count: 1, gap: 48, withSeparator: false });
expect((secondBreak as FlowBlock).type).toBe('continuous'); // Second sectPr
});
@@ -1198,7 +1199,7 @@ describe('toFlowBlocks', () => {
// Should emit the section break despite paragraph having content
expect(contentBreak).toBeDefined();
- expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48 });
+ expect((contentBreak as FlowBlock).columns).toEqual({ count: 2, gap: 48, withSeparator: false });
});
it('detects column changes from single to multi to single column', () => {
diff --git a/packages/layout-engine/pm-adapter/src/integration.test.ts b/packages/layout-engine/pm-adapter/src/integration.test.ts
index e239d81850..065b52b128 100644
--- a/packages/layout-engine/pm-adapter/src/integration.test.ts
+++ b/packages/layout-engine/pm-adapter/src/integration.test.ts
@@ -19,6 +19,7 @@ import twoColumnFixture from './fixtures/two-column-two-page.json';
import tabsDecimalFixture from './fixtures/tabs-decimal.json';
import tabsCenterEndFixture from './fixtures/tabs-center-end.json';
import paragraphPPrVariationsFixture from './fixtures/paragraph_pPr_variations.json';
+import { twipsToPx } from './utilities.js';
const DEFAULT_CONVERTER_CONTEXT = {
docx: {},
@@ -292,7 +293,12 @@ describe('PM → FlowBlock → Measure integration', () => {
const decimalMeasure = expectParagraphMeasure(await measureBlock(blocks[0], 400));
const controlMeasure = expectParagraphMeasure(await measureBlock(controlBlocks[0], 400));
- expect(decimalMeasure.lines[0].width).toBeLessThanOrEqual(controlMeasure.lines[0].width);
+ const rightAlignedStopTwips = blocks[0].attrs?.tabs?.find((stop) => stop.val === 'end')?.pos;
+ if (typeof rightAlignedStopTwips === 'number') {
+ expect(decimalMeasure.lines[0].width).toBeCloseTo(twipsToPx(rightAlignedStopTwips), 2);
+ }
+ // Decimal-aligned measurement should reserve at least as much width as the control case
+ expect(decimalMeasure.lines[0].width).toBeGreaterThanOrEqual(controlMeasure.lines[0].width);
});
it('derives default decimal separator from document language when not explicitly set', async () => {
diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts
index 37c60496b5..ac860ce7e6 100644
--- a/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/analysis.test.ts
@@ -756,7 +756,7 @@ describe('analysis', () => {
footerPx: 50,
pageSizePx: { w: 12240, h: 15840 },
orientation: 'landscape',
- columnsPx: { count: 2, gap: 100 },
+ columnsPx: { count: 2, gap: 100, withSeparator: false },
headerRefs: { default: 'header1' },
footerRefs: { default: 'footer1' },
numbering: { format: 'decimal', start: 1 },
@@ -770,7 +770,7 @@ describe('analysis', () => {
margins: { header: 100, footer: 50 },
pageSize: { w: 12240, h: 15840 },
orientation: 'landscape',
- columns: { count: 2, gap: 100 },
+ columns: { count: 2, gap: 100, withSeparator: false },
headerRefs: { default: 'header1' },
footerRefs: { default: 'footer1' },
numbering: { format: 'decimal', start: 1 },
@@ -962,6 +962,32 @@ describe('analysis', () => {
expect(result!.numbering).toEqual({ format: 'decimal', start: 5 });
});
+ it('should have column separator flag set to true when present in extracted data', () => {
+ const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' };
+
+ vi.mocked(extractionModule.extractSectionData).mockReturnValue({
+ titlePg: false,
+ columnsPx: { count: 2, gap: 48, withSeparator: true },
+ });
+
+ const result = createFinalSectionFromBodySectPr(bodySectPr, 0, 10, 0);
+
+ expect(result!.columns).toEqual({ count: 2, gap: 48, withSeparator: true });
+ });
+
+ it('should have column separator flag set to false when present as "false" in extracted data', () => {
+ const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' };
+
+ vi.mocked(extractionModule.extractSectionData).mockReturnValue({
+ titlePg: false,
+ columnsPx: { count: 2, gap: 48, withSeparator: false },
+ });
+
+ const result = createFinalSectionFromBodySectPr(bodySectPr, 0, 10, 0);
+
+ expect(result!.columns).toEqual({ count: 2, gap: 48, withSeparator: false });
+ });
+
it('should respect body section type from extracted data', () => {
const bodySectPr: SectPrElement = { type: 'element', name: 'w:sectPr' };
diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts
index 7f3fa6beec..ce6a124e96 100644
--- a/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/extraction.test.ts
@@ -250,6 +250,7 @@ describe('extraction', () => {
expect(result?.columnsPx).toEqual({
count: 2,
gap: 48, // 720 twips = 0.5 inches = 48 pixels
+ withSeparator: false,
});
});
@@ -281,6 +282,7 @@ describe('extraction', () => {
expect(result?.columnsPx).toEqual({
count: 2,
gap: 101.53333333333333,
+ withSeparator: false,
widths: [72, 497.26666666666665],
equalWidth: false,
});
@@ -374,6 +376,129 @@ describe('extraction', () => {
});
});
+ // ==================== extractSectionData - column separator (w:sep) tests ====================
+ describe('extractSectionData - column separator', () => {
+ it('should include separator when w:sep="1"', () => {
+ const para: PMNode = {
+ type: 'paragraph',
+ attrs: {
+ paragraphProperties: {
+ sectPr: {
+ type: 'element',
+ name: 'w:sectPr',
+ elements: [
+ {
+ name: 'w:cols',
+ attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': '1' },
+ },
+ ],
+ },
+ },
+ },
+ };
+ const result = extractSectionData(para);
+
+ expect(result).not.toBeNull();
+ expect(result?.columnsPx).toEqual({
+ count: 2,
+ gap: 48,
+ withSeparator: true,
+ });
+ });
+
+ it('should include separator when w:sep="true"', () => {
+ const para: PMNode = {
+ type: 'paragraph',
+ attrs: {
+ paragraphProperties: {
+ sectPr: {
+ type: 'element',
+ name: 'w:sectPr',
+ elements: [
+ {
+ name: 'w:cols',
+ attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': 'true' },
+ },
+ ],
+ },
+ },
+ },
+ };
+ const result = extractSectionData(para);
+
+ expect(result?.columnsPx?.withSeparator).toBe(true);
+ });
+
+ it('should include separator when w:sep="on"', () => {
+ const para: PMNode = {
+ type: 'paragraph',
+ attrs: {
+ paragraphProperties: {
+ sectPr: {
+ type: 'element',
+ name: 'w:sectPr',
+ elements: [
+ {
+ name: 'w:cols',
+ attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': 'on' },
+ },
+ ],
+ },
+ },
+ },
+ };
+ const result = extractSectionData(para);
+
+ expect(result?.columnsPx?.withSeparator).toBe(true);
+ });
+
+ it('should not include separator when w:sep is absent', () => {
+ const para: PMNode = {
+ type: 'paragraph',
+ attrs: {
+ paragraphProperties: {
+ sectPr: {
+ type: 'element',
+ name: 'w:sectPr',
+ elements: [
+ {
+ name: 'w:cols',
+ attributes: { 'w:num': '2', 'w:space': '720' },
+ },
+ ],
+ },
+ },
+ },
+ };
+ const result = extractSectionData(para);
+
+ expect(result?.columnsPx).toEqual({ count: 2, gap: 48, withSeparator: false });
+ });
+
+ it('should not include separator when w:sep="0"', () => {
+ const para: PMNode = {
+ type: 'paragraph',
+ attrs: {
+ paragraphProperties: {
+ sectPr: {
+ type: 'element',
+ name: 'w:sectPr',
+ elements: [
+ {
+ name: 'w:cols',
+ attributes: { 'w:num': '2', 'w:space': '720', 'w:sep': '0' },
+ },
+ ],
+ },
+ },
+ },
+ };
+ const result = extractSectionData(para);
+
+ expect(result?.columnsPx).toEqual({ count: 2, gap: 48, withSeparator: false });
+ });
+ });
+
// ==================== parseColumnGap Tests ====================
describe('parseColumnGap', () => {
it('should return default 0.5 inches when gapTwips is undefined', () => {
diff --git a/packages/layout-engine/pm-adapter/src/sections/extraction.ts b/packages/layout-engine/pm-adapter/src/sections/extraction.ts
index ca2e9979ef..da98daeec3 100644
--- a/packages/layout-engine/pm-adapter/src/sections/extraction.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/extraction.ts
@@ -6,6 +6,7 @@
import type { PMNode } from '../types.js';
import type { ParagraphProperties, SectionVerticalAlign } from './types.js';
+import type { ColumnLayout } from '@superdoc/contracts';
const TWIPS_PER_INCH = 1440;
const PX_PER_INCH = 96;
@@ -42,6 +43,15 @@ export function parseColumnGap(gapTwips: string | number | undefined): number {
return Number.isFinite(gap) ? gap / TWIPS_PER_INCH : DEFAULT_COLUMN_GAP_INCHES;
}
+/**
+ * Parse presence of column separator from w:sep attribute (can be '1', 'true' or 'on').
+ * @param rawValue - Raw value from w:sep attribute
+ * @returns Presence of column separator
+ */
+export function parseColumnSeparator(rawValue: string | number | undefined): boolean {
+ return rawValue === '1' || rawValue === 'true' || rawValue === 'on' || rawValue === 1;
+}
+
type SectionType = 'continuous' | 'nextPage' | 'evenPage' | 'oddPage';
type Orientation = 'portrait' | 'landscape';
type HeaderRefType = Partial>;
@@ -209,13 +219,12 @@ function extractPageNumbering(elements: SectionElement[]):
/**
* Extract columns from element.
*/
-function extractColumns(
- elements: SectionElement[],
-): { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | undefined {
+function extractColumns(elements: SectionElement[]): ColumnLayout | undefined {
const cols = elements.find((el) => el?.name === 'w:cols');
if (!cols?.attributes) return undefined;
const count = parseColumnCount(cols.attributes['w:num'] as string | number | undefined);
+ const withSeparator = parseColumnSeparator(cols.attributes['w:sep'] as string | number | undefined);
const equalWidthRaw = cols.attributes['w:equalWidth'];
const equalWidth =
equalWidthRaw === '0' || equalWidthRaw === 0 || equalWidthRaw === false
@@ -233,9 +242,10 @@ function extractColumns(
.filter((widthTwips) => Number.isFinite(widthTwips) && widthTwips > 0)
.map((widthTwips) => (widthTwips / 1440) * PX_PER_INCH);
- const result = {
+ const result: ColumnLayout = {
count,
gap: gapInches * PX_PER_INCH,
+ withSeparator,
...(widths.length > 0 ? { widths } : {}),
...(equalWidth !== undefined ? { equalWidth } : {}),
};
@@ -308,7 +318,7 @@ export function extractSectionData(para: PMNode): {
type?: SectionType;
pageSizePx?: { w: number; h: number };
orientation?: Orientation;
- columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean };
+ columnsPx?: ColumnLayout;
titlePg?: boolean;
headerRefs?: HeaderRefType;
footerRefs?: HeaderRefType;
diff --git a/packages/layout-engine/pm-adapter/src/sections/index.ts b/packages/layout-engine/pm-adapter/src/sections/index.ts
index b21f849c6a..64b41423fb 100644
--- a/packages/layout-engine/pm-adapter/src/sections/index.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/index.ts
@@ -17,7 +17,7 @@ export type {
export { SectionType, DEFAULT_PARAGRAPH_SECTION_TYPE, DEFAULT_BODY_SECTION_TYPE } from './types.js';
// Extraction
-export { extractSectionData, parseColumnCount, parseColumnGap } from './extraction.js';
+export { extractSectionData, parseColumnCount, parseColumnGap, parseColumnSeparator } from './extraction.js';
// Analysis
export {
diff --git a/packages/layout-engine/pm-adapter/src/sections/types.ts b/packages/layout-engine/pm-adapter/src/sections/types.ts
index bc8498ffed..a4134b48d0 100644
--- a/packages/layout-engine/pm-adapter/src/sections/types.ts
+++ b/packages/layout-engine/pm-adapter/src/sections/types.ts
@@ -5,6 +5,8 @@
* Includes section ranges, signatures, and OOXML structures.
*/
+import type { ColumnLayout } from '@superdoc/contracts';
+
/**
* Section types in Word documents.
* Controls how section breaks create new pages.
@@ -72,7 +74,7 @@ export type SectionSignature = {
orientation?: 'portrait' | 'landscape';
headerRefs?: Partial>;
footerRefs?: Partial>;
- columnsPx?: { count: number; gap: number; widths?: number[]; equalWidth?: boolean };
+ columnsPx?: ColumnLayout;
numbering?: {
format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash';
start?: number;
@@ -105,7 +107,7 @@ export interface SectionRange {
} | null;
pageSize: { w: number; h: number } | null;
orientation: 'portrait' | 'landscape' | null;
- columns: { count: number; gap: number; widths?: number[]; equalWidth?: boolean } | null;
+ columns: ColumnLayout | null;
type: SectionType;
titlePg: boolean;
headerRefs?: Partial>;
diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx
index 6c469273ea..b944661d85 100644
--- a/packages/react/src/SuperDocEditor.test.tsx
+++ b/packages/react/src/SuperDocEditor.test.tsx
@@ -117,6 +117,46 @@ describe('SuperDocEditor', () => {
{ timeout: 5000 },
);
});
+
+ it('should route onTransaction through the latest callback after rerender', async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const firstOnTransaction = vi.fn();
+ const secondOnTransaction = vi.fn();
+
+ const { rerender } = render();
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+
+ const instance = ref.current?.getInstance();
+ expect(instance).toBeTruthy();
+
+ const transactionEvent = {
+ editor: {},
+ sourceEditor: {},
+ transaction: { docChanged: true },
+ surface: 'body',
+ };
+
+ const firstCallCountBeforeManualDispatch = firstOnTransaction.mock.calls.length;
+ (instance as any).config.onTransaction(transactionEvent);
+
+ expect(firstOnTransaction).toHaveBeenLastCalledWith(transactionEvent);
+ expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeManualDispatch + 1);
+ expect(secondOnTransaction).not.toHaveBeenCalled();
+
+ rerender();
+
+ expect(ref.current?.getInstance()).toBe(instance);
+
+ const firstCallCountBeforeRerenderDispatch = firstOnTransaction.mock.calls.length;
+ const secondCallCountBeforeManualDispatch = secondOnTransaction.mock.calls.length;
+ (instance as any).config.onTransaction(transactionEvent);
+
+ expect(firstOnTransaction).toHaveBeenCalledTimes(firstCallCountBeforeRerenderDispatch);
+ expect(secondOnTransaction).toHaveBeenLastCalledWith(transactionEvent);
+ expect(secondOnTransaction).toHaveBeenCalledTimes(secondCallCountBeforeManualDispatch + 1);
+ });
});
describe('onEditorDestroy', () => {
@@ -175,6 +215,213 @@ describe('SuperDocEditor', () => {
});
});
+ describe('prop stability (SD-2635)', () => {
+ it('does not destroy/re-init when user prop is a new object literal with identical content', async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+ const instanceBefore = ref.current?.getInstance();
+ expect(instanceBefore).toBeTruthy();
+
+ // Re-render with a *new* object literal carrying the same content —
+ // this is the idiomatic React pattern that used to trigger a full
+ // destroy + re-init loop before SD-2635.
+ rerender(
+ ,
+ );
+
+ // Same underlying instance proves no destroy+rebuild happened.
+ expect(ref.current?.getInstance()).toBe(instanceBefore);
+ expect(onEditorDestroy).not.toHaveBeenCalled();
+ });
+
+ it('does not destroy/re-init when users prop is a new array literal with identical content', async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+ const instanceBefore = ref.current?.getInstance();
+
+ rerender(
+ ,
+ );
+
+ expect(ref.current?.getInstance()).toBe(instanceBefore);
+ expect(onEditorDestroy).not.toHaveBeenCalled();
+ });
+
+ it('rebuilds and remounts a new instance when user prop value actually changes', async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+ const instanceBefore = ref.current?.getInstance();
+
+ rerender(
+ ,
+ );
+
+ // Old instance torn down, new instance ready.
+ await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
+ await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 });
+ expect(ref.current?.getInstance()).not.toBe(instanceBefore);
+ });
+
+ it('stays stable under StrictMode double-invocation on rerender', async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+ const instanceBefore = ref.current?.getInstance();
+ const destroysBefore = onEditorDestroy.mock.calls.length;
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(ref.current?.getInstance()).toBe(instanceBefore);
+ expect(onEditorDestroy.mock.calls.length).toBe(destroysBefore);
+ });
+
+ it('still rebuilds under StrictMode when user prop value actually changes', async () => {
+ // The same-content StrictMode test above proves memoization survives
+ // double-invocation. This test proves the positive path — a real
+ // value change under StrictMode still tears down and remounts.
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+ const instanceBefore = ref.current?.getInstance();
+
+ rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
+ await waitFor(() => expect(ref.current?.getInstance()).not.toBe(instanceBefore), { timeout: 5000 });
+ });
+
+ it('rebuilds when a new modules object is passed, even if content looks equal', async () => {
+ // `modules` is intentionally kept on reference identity in the dep
+ // array because it can carry functions and live objects that a
+ // structural compare would miss. This test pins that contract —
+ // if a future refactor wraps `modules` in useStructuralMemo, this
+ // test will fail and flag the regression.
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onEditorDestroy = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 });
+ const instanceBefore = ref.current?.getInstance();
+
+ rerender(
+ ,
+ );
+
+ await waitFor(() => expect(onEditorDestroy).toHaveBeenCalled(), { timeout: 5000 });
+ await waitFor(() => expect(onReady).toHaveBeenCalledTimes(2), { timeout: 5000 });
+ expect(ref.current?.getInstance()).not.toBe(instanceBefore);
+ });
+ });
+
describe('unique IDs', () => {
it('should generate unique container IDs for multiple instances', () => {
const { container: container1 } = render();
diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx
index 33662a5555..b022552456 100644
--- a/packages/react/src/SuperDocEditor.tsx
+++ b/packages/react/src/SuperDocEditor.tsx
@@ -7,7 +7,7 @@ import {
type CSSProperties,
type ForwardedRef,
} from 'react';
-import { useStableId } from './utils';
+import { useStableId, useMemoByValue } from './utils';
import type {
CallbackProps,
DocumentMode,
@@ -17,6 +17,7 @@ import type {
SuperDocReadyEvent,
SuperDocEditorCreateEvent,
SuperDocEditorUpdateEvent,
+ SuperDocTransactionEvent,
SuperDocContentErrorEvent,
SuperDocExceptionEvent,
} from './types';
@@ -46,12 +47,13 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null);
const toolbarContainerRef = useRef(null);
@@ -78,6 +87,7 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null);
@@ -185,6 +196,11 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef {
+ if (!destroyed) {
+ callbacksRef.current.onTransaction?.(event);
+ }
+ },
onContentError: (event: SuperDocContentErrorEvent) => {
if (!destroyed) {
callbacksRef.current.onContentError?.(event);
@@ -223,8 +239,8 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef>[0];
+/** Surface where an editor event originated. */
+export type EditorSurface = 'body' | 'header' | 'footer';
-/** Event passed to onTransaction callback */
-export type SuperDocTransactionEvent = Parameters>[0];
+/** Event passed to onEditorUpdate callback. Mirrors superdoc's EditorUpdateEvent. */
+export interface SuperDocEditorUpdateEvent {
+ /** The primary editor associated with the update. For header/footer edits, this is the main body editor. */
+ editor: Editor;
+ /** The editor instance that emitted the update. For body edits, this matches `editor`. */
+ sourceEditor: Editor;
+ /** The surface where the edit originated. */
+ surface: EditorSurface;
+ /** Relationship ID for header/footer edits. */
+ headerId?: string | null;
+ /** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */
+ sectionType?: string | null;
+}
+
+/** Event passed to onTransaction callback. Mirrors superdoc's EditorTransactionEvent. */
+export interface SuperDocTransactionEvent {
+ /** The primary editor associated with the transaction. For header/footer edits, this is the main body editor. */
+ editor: Editor;
+ /** The editor instance that emitted the transaction. For body edits, this matches `editor`. */
+ sourceEditor: Editor;
+ /** The ProseMirror transaction or transaction-like payload emitted by the source editor. */
+ transaction: any;
+ /** Time spent applying the transaction, in milliseconds. */
+ duration?: number;
+ /** The surface where the transaction originated. */
+ surface: EditorSurface;
+ /** Relationship ID for header/footer edits. */
+ headerId?: string | null;
+ /** Header/footer variant (`default`, `first`, `even`, `odd`) when available. */
+ sectionType?: string | null;
+}
/** Event passed to onContentError callback */
export interface SuperDocContentErrorEvent {
@@ -96,6 +125,7 @@ type ExplicitCallbackProps =
| 'onEditorCreate'
| 'onEditorDestroy'
| 'onEditorUpdate'
+ | 'onTransaction'
| 'onContentError'
| 'onException';
@@ -116,6 +146,9 @@ export interface CallbackProps {
/** Callback when document content is updated */
onEditorUpdate?: (event: SuperDocEditorUpdateEvent) => void;
+ /** Callback when a transaction is emitted */
+ onTransaction?: (event: SuperDocTransactionEvent) => void;
+
/** Callback when there is a content parsing error */
onContentError?: (event: SuperDocContentErrorEvent) => void;
diff --git a/packages/react/src/utils.test.ts b/packages/react/src/utils.test.ts
new file mode 100644
index 0000000000..6e651fb7d3
--- /dev/null
+++ b/packages/react/src/utils.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useMemoByValue } from './utils';
+
+describe('useMemoByValue', () => {
+ it('returns the same reference across renders when content is unchanged', () => {
+ const initial = { name: 'Alex', email: 'alex@example.com' };
+ const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
+ initialProps: { value: initial },
+ });
+
+ const first = result.current;
+ expect(first).toBe(initial);
+
+ // Parent passes a fresh object literal with identical content
+ rerender({ value: { name: 'Alex', email: 'alex@example.com' } });
+ expect(result.current).toBe(first); // same reference — critical for effect deps
+
+ // And again, still stable
+ rerender({ value: { name: 'Alex', email: 'alex@example.com' } });
+ expect(result.current).toBe(first);
+ });
+
+ it('returns a new reference when the content actually changes', () => {
+ const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
+ initialProps: { value: { name: 'Alex' } },
+ });
+
+ const first = result.current;
+ rerender({ value: { name: 'Jamie' } });
+ expect(result.current).not.toBe(first);
+ expect(result.current.name).toBe('Jamie');
+ });
+
+ it('handles undefined and null stably', () => {
+ const { result, rerender } = renderHook(({ value }) => useMemoByValue(value as unknown), {
+ initialProps: { value: undefined },
+ });
+
+ const first = result.current;
+ rerender({ value: undefined });
+ expect(result.current).toBe(first);
+
+ rerender({ value: null });
+ expect(result.current).toBe(null);
+ });
+
+ it('stabilizes arrays the same way as objects', () => {
+ const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
+ initialProps: { value: [{ id: 1 }, { id: 2 }] },
+ });
+
+ const first = result.current;
+ rerender({ value: [{ id: 1 }, { id: 2 }] });
+ expect(result.current).toBe(first);
+
+ rerender({ value: [{ id: 1 }, { id: 3 }] });
+ expect(result.current).not.toBe(first);
+ });
+
+ it('adopts a new reference on circular input (JSON.stringify throws)', () => {
+ const circularA: { self?: unknown; name: string } = { name: 'a' };
+ circularA.self = circularA;
+
+ const { result, rerender } = renderHook(({ value }) => useMemoByValue(value), {
+ initialProps: { value: circularA },
+ });
+
+ const circularB: { self?: unknown; name: string } = { name: 'a' };
+ circularB.self = circularB;
+ rerender({ value: circularB });
+ // The compare throws; the hook falls back to adopting the new reference.
+ expect(result.current).toBe(circularB);
+ });
+});
diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts
index 6162d2b8f1..782e8fe7af 100644
--- a/packages/react/src/utils.ts
+++ b/packages/react/src/utils.ts
@@ -25,3 +25,42 @@ function useIdPolyfill(): string {
*/
export const useStableId: () => string =
typeof (React as any).useId === 'function' ? (React as any).useId : useIdPolyfill;
+
+/**
+ * Returns a reference-stable version of `value` — identity only changes
+ * when the serialized content changes.
+ *
+ * Use for plain-data object/array props that feed into `useEffect` /
+ * `useMemo` dependency arrays when the consumer is likely to pass inline
+ * literals. Without this, every parent re-render produces a fresh
+ * reference and causes the effect to re-run even when the content is
+ * identical.
+ *
+ * Not suitable for values containing functions, class instances (Yjs
+ * Doc, Maps, Sets, Dates), or circular references — JSON.stringify
+ * drops or collapses those. The compare only runs when the incoming
+ * reference differs, so the steady-state cost is a single pointer check.
+ */
+export function useMemoByValue(value: T): T {
+ const lastRawRef = React.useRef(value);
+ const stableRef = React.useRef(value);
+
+ if (lastRawRef.current !== value) {
+ if (!shallowJsonEqual(stableRef.current, value)) {
+ stableRef.current = value;
+ }
+ lastRawRef.current = value;
+ }
+
+ return stableRef.current;
+}
+
+function shallowJsonEqual(a: T, b: T): boolean {
+ if (a === b) return true;
+ if (a == null || b == null) return a === b;
+ try {
+ return JSON.stringify(a) === JSON.stringify(b);
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py
index 9682645771..fa0b628a52 100644
--- a/packages/sdk/langs/python/superdoc/__init__.py
+++ b/packages/sdk/langs/python/superdoc/__init__.py
@@ -10,6 +10,7 @@
get_tool_catalog,
list_tools,
)
+from .transport import DEFAULT_STDOUT_BUFFER_LIMIT_BYTES
__all__ = [
"SuperDocClient",
@@ -17,6 +18,7 @@
"SuperDocDocument",
"AsyncSuperDocDocument",
"SuperDocError",
+ "DEFAULT_STDOUT_BUFFER_LIMIT_BYTES",
"get_skill",
"install_skill",
"list_skills",
diff --git a/packages/sdk/langs/python/superdoc/client.py b/packages/sdk/langs/python/superdoc/client.py
index f187895b04..9291399518 100644
--- a/packages/sdk/langs/python/superdoc/client.py
+++ b/packages/sdk/langs/python/superdoc/client.py
@@ -22,6 +22,7 @@
DocOpenResult as GeneratedDocOpenResult,
)
from .runtime import SuperDocAsyncRuntime, SuperDocSyncRuntime
+from .transport import DEFAULT_STDOUT_BUFFER_LIMIT_BYTES
UserIdentity = Dict[str, str]
@@ -340,6 +341,9 @@ def __init__(
request_timeout_ms: int | None = None,
watchdog_timeout_ms: int = 30_000,
max_queue_depth: int = 100,
+ # Raise if a single host response can exceed this size (e.g. reading
+ # very large documents); otherwise the default is safe.
+ stdout_buffer_limit_bytes: int = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES,
default_change_mode: Literal['direct', 'tracked'] | None = None,
user: UserIdentity | None = None,
) -> None:
@@ -350,6 +354,7 @@ def __init__(
request_timeout_ms=request_timeout_ms,
watchdog_timeout_ms=watchdog_timeout_ms,
max_queue_depth=max_queue_depth,
+ stdout_buffer_limit_bytes=stdout_buffer_limit_bytes,
default_change_mode=default_change_mode,
user=user,
)
diff --git a/packages/sdk/langs/python/superdoc/runtime.py b/packages/sdk/langs/python/superdoc/runtime.py
index e303754502..25dc3727e0 100644
--- a/packages/sdk/langs/python/superdoc/runtime.py
+++ b/packages/sdk/langs/python/superdoc/runtime.py
@@ -14,7 +14,11 @@
from .embedded_cli import resolve_embedded_cli_path
from .generated.contract import OPERATION_INDEX
from .protocol import normalize_default_change_mode
-from .transport import AsyncHostTransport, SyncHostTransport
+from .transport import (
+ DEFAULT_STDOUT_BUFFER_LIMIT_BYTES,
+ AsyncHostTransport,
+ SyncHostTransport,
+)
class SuperDocSyncRuntime:
@@ -79,6 +83,7 @@ def __init__(
request_timeout_ms: Optional[int] = None,
watchdog_timeout_ms: int = 30_000,
max_queue_depth: int = 100,
+ stdout_buffer_limit_bytes: int = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES,
default_change_mode: Optional[str] = None,
user: Optional[Dict[str, str]] = None,
) -> None:
@@ -93,6 +98,7 @@ def __init__(
request_timeout_ms=request_timeout_ms,
watchdog_timeout_ms=watchdog_timeout_ms,
max_queue_depth=max_queue_depth,
+ stdout_buffer_limit_bytes=stdout_buffer_limit_bytes,
default_change_mode=self._default_change_mode,
user=user,
)
diff --git a/packages/sdk/langs/python/superdoc/transport.py b/packages/sdk/langs/python/superdoc/transport.py
index fec79e9c8e..d7c4a9ca0e 100644
--- a/packages/sdk/langs/python/superdoc/transport.py
+++ b/packages/sdk/langs/python/superdoc/transport.py
@@ -43,6 +43,12 @@
logger = logging.getLogger('superdoc.transport')
+# Default stdout StreamReader buffer for the async transport. Host responses
+# are single newline-delimited JSON lines, so this caps the largest individual
+# response a caller can receive. Raise it if your workload routinely produces
+# responses above this size (e.g. whole-document reads on very large docs).
+DEFAULT_STDOUT_BUFFER_LIMIT_BYTES = 64 * 1024 * 1024
+
# Opt-in debug logging via SUPERDOC_DEBUG=1 or SUPERDOC_LOG_LEVEL=debug.
# Only configures the named logger — never mutates root logging config.
_log_level = os.environ.get('SUPERDOC_LOG_LEVEL', '').lower()
@@ -399,6 +405,7 @@ def __init__(
request_timeout_ms: Optional[int] = None,
watchdog_timeout_ms: int = 30_000,
max_queue_depth: int = 100,
+ stdout_buffer_limit_bytes: int = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES,
default_change_mode: Optional[ChangeMode] = None,
user: Optional[Dict[str, str]] = None,
) -> None:
@@ -409,11 +416,13 @@ def __init__(
self._request_timeout_ms = request_timeout_ms
self._watchdog_timeout_ms = watchdog_timeout_ms
self._max_queue_depth = max_queue_depth
+ self._stdout_buffer_limit_bytes = stdout_buffer_limit_bytes
self._default_change_mode = default_change_mode
self._user = user
self._process: Optional[asyncio.subprocess.Process] = None
self._reader_task: Optional[asyncio.Task] = None
+ self._cleanup_task: Optional[asyncio.Task] = None
self._pending: Dict[int, asyncio.Future] = {}
self._state = _State.DISCONNECTED
self._next_request_id = 1
@@ -428,7 +437,22 @@ async def connect(self) -> None:
async def dispose(self) -> None:
"""Gracefully shut down the host process."""
- if self._state == _State.DISCONNECTED or self._state == _State.DISPOSING:
+ if self._state == _State.DISCONNECTED:
+ return
+ if self._state == _State.DISPOSING:
+ # A reader-triggered cleanup is in flight (or an earlier teardown
+ # left state in DISPOSING briefly). Wait for it so the caller
+ # observes "host fully torn down" by the time dispose() returns.
+ # shield() so a cancelled dispose() doesn't interrupt _cleanup
+ # mid-flight and leak the host process.
+ existing = self._cleanup_task
+ if existing and not existing.done():
+ try:
+ await asyncio.shield(existing)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ pass
return
self._stopping = True
@@ -507,6 +531,20 @@ async def invoke(
async def _ensure_connected(self) -> None:
"""Lazy connect: spawn and handshake if not already connected."""
+ # Drain any in-flight teardown before spawning a new host. Without
+ # this, a concurrent reader-triggered cleanup would still be running
+ # when _start_host reassigns self._process / self._reader_task; the
+ # cleanup task would then cancel the fresh reader and kill the fresh
+ # process. shield() so we don't cancel the cleanup if our caller is.
+ cleanup = self._cleanup_task
+ if cleanup and not cleanup.done():
+ try:
+ await asyncio.shield(cleanup)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ pass
+
if self._state == _State.CONNECTED and self._process and self._process.returncode is None:
return
@@ -531,12 +569,15 @@ async def _start_host(self) -> None:
args = [*prefix_args, 'host', '--stdio']
try:
+ # ``limit`` raises asyncio's StreamReader buffer above its 64 KiB
+ # default; host responses are single JSON lines and can exceed it.
self._process = await asyncio.create_subprocess_exec(
command, *args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
env={**os.environ, **self._env},
+ limit=self._stdout_buffer_limit_bytes,
)
logger.debug('Host spawned (pid=%s, bin=%s).', self._process.pid, self._cli_bin)
except Exception as exc:
@@ -582,7 +623,29 @@ async def _reader_loop(self) -> None:
try:
while True:
- raw = await process.stdout.readline()
+ try:
+ raw = await process.stdout.readline()
+ except ValueError as exc:
+ # asyncio.StreamReader.readline() re-raises LimitOverrunError
+ # from readuntil() as ValueError when a single line exceeds
+ # `limit` (see CPython asyncio/streams.py). The host is still
+ # alive — schedule cleanup so a later dispose() doesn't
+ # short-circuit on DISCONNECTED state. Scoped to readline()
+ # only so unrelated ValueErrors from dispatch aren't
+ # reclassified as a buffer-limit error. _schedule_cleanup
+ # is a no-op when _stopping is set (graceful dispose path).
+ logger.debug('Reader loop buffer overflow: %s', exc)
+ self._schedule_cleanup(SuperDocError(
+ 'Host response exceeded stdout buffer limit. '
+ 'Raise stdout_buffer_limit_bytes to accommodate larger responses.',
+ code=HOST_PROTOCOL_ERROR,
+ details={
+ 'message': str(exc),
+ 'stdout_buffer_limit_bytes': self._stdout_buffer_limit_bytes,
+ },
+ ))
+ return
+
if not raw:
# EOF — process died.
break
@@ -614,16 +677,16 @@ async def _reader_loop(self) -> None:
except Exception as exc:
logger.debug('Reader loop error: %s', exc)
- # Reader exited (EOF or error) — reject all pending futures.
- if not self._stopping:
- exit_code = process.returncode
- error = SuperDocError(
- 'Host process disconnected.',
- code=HOST_DISCONNECTED,
- details={'exit_code': exit_code, 'signal': None},
- )
- self._reject_all_pending(error)
- self._state = _State.DISCONNECTED
+ # Reader exited (EOF or unexpected error) — tear down the process so
+ # no orphaned host is left running, then reject pending futures.
+ # _schedule_cleanup is a no-op when _stopping is set (graceful
+ # dispose path) so we don't race the dispose teardown.
+ exit_code = process.returncode
+ self._schedule_cleanup(SuperDocError(
+ 'Host process disconnected.',
+ code=HOST_DISCONNECTED,
+ details={'exit_code': exit_code, 'signal': None},
+ ))
async def _send_request(self, method: str, params: Any, watchdog_ms: int) -> Any:
"""Send a JSON-RPC request and await the matching response future."""
@@ -687,34 +750,88 @@ def _reject_all_pending(self, error: SuperDocError) -> None:
future.set_exception(error)
async def _kill_and_reset(self) -> None:
- """Kill the host process and reset to DISCONNECTED."""
- await self._cleanup(
- SuperDocError('Host process disconnected.', code=HOST_DISCONNECTED),
- )
-
- async def _cleanup(self, error: Optional[SuperDocError]) -> None:
- """Cancel reader, kill process, reject pending, reset state."""
- if self._reader_task and not self._reader_task.done():
- self._reader_task.cancel()
+ """Kill the host process and reset to DISCONNECTED.
+
+ Coordinates with `_schedule_cleanup` so callers (e.g. `_send_request`
+ on watchdog timeout or stdin write failure) don't run a parallel
+ `_cleanup` that races a reader-triggered cleanup on
+ `_reject_all_pending` and `process.kill`. If a cleanup is already in
+ flight, await it; otherwise own a fresh task in the same slot so a
+ later concurrent caller sees us instead of starting its own.
+
+ shield() the await so caller cancellation (e.g. an `invoke()` task
+ that times out and is then cancelled by the user) does NOT propagate
+ into `_cleanup` — interrupting cleanup mid-flight would leak the
+ subprocess and wedge state in DISPOSING.
+ """
+ existing = self._cleanup_task
+ if existing and not existing.done():
try:
- await self._reader_task
- except (asyncio.CancelledError, Exception):
+ await asyncio.shield(existing)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
pass
- self._reader_task = None
+ return
+ self._state = _State.DISPOSING
+ task = asyncio.create_task(self._cleanup(
+ SuperDocError('Host process disconnected.', code=HOST_DISCONNECTED),
+ ))
+ self._cleanup_task = task
+ try:
+ await asyncio.shield(task)
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ pass
+ def _schedule_cleanup(self, error: SuperDocError) -> None:
+ """Fire-and-forget teardown from inside the reader task.
+
+ Why a separate task: `_cleanup` cancels and awaits `self._reader_task`.
+ Awaiting it from inside the reader itself would deadlock — so we punt
+ to a fresh task, and by the time it runs the reader has already
+ returned (so cancel+await is a no-op).
+
+ Synchronously flips state to DISPOSING so concurrent `invoke()` callers
+ observe the failed transport immediately rather than passing the
+ CONNECTED fast path and blocking on a future the dead reader can never
+ resolve until `watchdog_timeout_ms`.
+
+ Skips when `_stopping` is set: a graceful `dispose()` is already
+ tearing down, and a parallel cleanup task would race on
+ `_reject_all_pending` and `process.kill`.
+
+ Idempotent: if a cleanup is already in flight, subsequent errors are
+ dropped — the first one wins. Callers may observe completion via
+ `self._cleanup_task`.
+ """
+ if self._stopping:
+ return
+ if self._cleanup_task and not self._cleanup_task.done():
+ return
+ self._state = _State.DISPOSING
+ self._cleanup_task = asyncio.create_task(self._cleanup(error))
+
+ async def _cleanup(self, error: Optional[SuperDocError]) -> None:
+ """Cancel reader, kill process, reject pending, reset state.
+
+ Capture handles and flip user-visible state SYNCHRONOUSLY at the top
+ before any awaits. That way, even if cancellation arrives during
+ `process.wait()`, observers see a consistent "torn down" transport
+ (state DISCONNECTED, _process None, pending futures rejected) rather
+ than a half-disposed one. The async work below is best-effort
+ process reaping.
+ """
+ # Snapshot and clear before any await so concurrent callers see a
+ # fully torn-down transport from this point on.
+ reader_task = self._reader_task
process = self._process
- if process:
- try:
- process.kill()
- except Exception:
- pass
- try:
- await asyncio.wait_for(process.wait(), timeout=2)
- except (asyncio.TimeoutError, Exception):
- pass
+ self._reader_task = None
self._process = None
+ self._state = _State.DISCONNECTED
- if error:
+ if error is not None:
self._reject_all_pending(error)
else:
# Dispose path — reject remaining with generic disconnect.
@@ -722,4 +839,31 @@ async def _cleanup(self, error: Optional[SuperDocError]) -> None:
SuperDocError('Host process was disposed.', code=HOST_DISCONNECTED),
)
- self._state = _State.DISCONNECTED
+ try:
+ if reader_task and not reader_task.done():
+ reader_task.cancel()
+ try:
+ await reader_task
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ if process:
+ try:
+ process.kill()
+ except Exception:
+ pass
+ try:
+ await asyncio.wait_for(process.wait(), timeout=2)
+ except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
+ pass
+ finally:
+ # Release the task handle if we are the in-flight cleanup task,
+ # so introspection doesn't surface a stale done handle and the
+ # next teardown gets a fresh slot. Skip when called inline (e.g.
+ # from dispose) — that current task is not our cleanup task.
+ try:
+ current = asyncio.current_task()
+ except RuntimeError:
+ current = None
+ if current is not None and self._cleanup_task is current:
+ self._cleanup_task = None
diff --git a/packages/sdk/langs/python/tests/test_transport.py b/packages/sdk/langs/python/tests/test_transport.py
index d71bafa186..f54c0c7a71 100644
--- a/packages/sdk/langs/python/tests/test_transport.py
+++ b/packages/sdk/langs/python/tests/test_transport.py
@@ -24,7 +24,11 @@
HOST_TIMEOUT,
SuperDocError,
)
-from superdoc.transport import AsyncHostTransport, SyncHostTransport
+from superdoc.transport import (
+ DEFAULT_STDOUT_BUFFER_LIMIT_BYTES,
+ AsyncHostTransport,
+ SyncHostTransport,
+)
MOCK_HOST = os.path.join(os.path.dirname(__file__), 'mock_host.py')
@@ -447,9 +451,24 @@ async def test_host_crash(self):
try:
transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
await transport.connect()
+ process = transport._process
+ assert process is not None
with pytest.raises(SuperDocError) as exc_info:
await transport.invoke(_TEST_OP, {'query': 'test'})
- assert exc_info.value.code in (HOST_DISCONNECTED, HOST_TIMEOUT)
+ # The reader-loop EOF branch now goes through _schedule_cleanup,
+ # which rejects the pending future synchronously enough that the
+ # invoke() never has to fall back to the watchdog timeout.
+ assert exc_info.value.code == HOST_DISCONNECTED
+
+ # Cleanup must tear the process down — pre-fix, the inline
+ # _reject_all_pending + state flip left the process orphaned.
+ cleanup_task = transport._cleanup_task
+ if cleanup_task is not None:
+ await cleanup_task
+ assert transport._process is None
+ assert transport.state == 'DISCONNECTED'
+ await process.wait()
+ assert process.returncode is not None
finally:
_cleanup_wrapper(cli)
@@ -493,3 +512,432 @@ async def test_reuse_after_dispose(self):
await transport.dispose()
finally:
_cleanup_wrapper(cli2)
+
+
+class TestAsyncLargeResponse:
+ """Responses larger than the StreamReader buffer must not crash the reader."""
+
+ @pytest.mark.asyncio
+ async def test_response_above_asyncio_default_streamreader_limit(self):
+ big_payload = 'x' * (200 * 1024)
+ cli = _mock_cli_bin({
+ 'handshake': 'ok',
+ 'responses': [{'data': {'content': big_payload}}],
+ })
+ try:
+ transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
+ await transport.connect()
+ result = await transport.invoke(_TEST_OP, {'query': 'big'})
+ assert result == {'content': big_payload}
+ assert transport.state == 'CONNECTED'
+ await transport.dispose()
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_response_above_custom_buffer_limit_raises_protocol_error(self):
+ # Setting stdout_buffer_limit_bytes below the response size should
+ # surface HOST_PROTOCOL_ERROR (actionable) rather than
+ # HOST_DISCONNECTED (misleading — the host is still alive), and the
+ # error should carry a hint to raise the buffer limit.
+ from superdoc.errors import HOST_PROTOCOL_ERROR
+
+ big_payload = 'x' * (200 * 1024)
+ cli = _mock_cli_bin({
+ 'handshake': 'ok',
+ 'responses': [{'data': {'content': big_payload}}],
+ })
+ try:
+ transport = AsyncHostTransport(
+ cli,
+ startup_timeout_ms=5_000,
+ stdout_buffer_limit_bytes=64 * 1024,
+ )
+ await transport.connect()
+ process = transport._process
+ assert process is not None
+ with pytest.raises(SuperDocError) as exc_info:
+ await transport.invoke(_TEST_OP, {'query': 'big'})
+ assert exc_info.value.code == HOST_PROTOCOL_ERROR
+ assert 'stdout_buffer_limit_bytes' in str(exc_info.value)
+
+ # The host process must be torn down — not just the transport
+ # state flipped to DISCONNECTED. Otherwise dispose() short-circuits
+ # and leaves an orphaned host running.
+ cleanup_task = transport._cleanup_task
+ if cleanup_task is not None:
+ await cleanup_task
+ assert transport._process is None
+ assert transport.state == 'DISCONNECTED'
+ # The captured handle should be reaped by _cleanup; await wait()
+ # rather than reading returncode to avoid a CI-timing flake if the
+ # 2 s wait inside _cleanup didn't finish reaping in time.
+ await process.wait()
+ assert process.returncode is not None
+
+ # dispose() after an overflow must be a safe no-op: state and
+ # process stay as cleanup left them, no exception is raised, and
+ # a second dispose() is also safe.
+ await transport.dispose()
+ assert transport.state == 'DISCONNECTED'
+ assert transport._process is None
+ await transport.dispose()
+ assert transport.state == 'DISCONNECTED'
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_client_threads_stdout_buffer_limit_to_transport(self):
+ # End-to-end wiring check: the public AsyncSuperDocClient constructor
+ # must thread stdout_buffer_limit_bytes through SuperDocAsyncRuntime
+ # into AsyncHostTransport. Without this, a silent drop in client.py
+ # or runtime.py would leave the existing overflow test passing while
+ # the public API reverts to the asyncio 64 KiB default.
+ from superdoc.client import AsyncSuperDocClient
+
+ cli = _mock_cli_bin({'handshake': 'ok'})
+ try:
+ client = AsyncSuperDocClient(
+ env={'SUPERDOC_CLI_BIN': cli},
+ stdout_buffer_limit_bytes=64 * 1024,
+ )
+ transport = client._runtime._transport
+ assert transport._stdout_buffer_limit_bytes == 64 * 1024
+ finally:
+ _cleanup_wrapper(cli)
+
+
+class TestAsyncCleanupLifecycle:
+ """Lock down the cleanup-task slot so its load-bearing invariants don't
+ silently regress: the dedupe guard, the _stopping suppression branch,
+ the _kill_and_reset coordination with reader-triggered cleanup, and the
+ _ensure_connected drain that prevents stale cleanup from killing a
+ freshly-spawned host.
+ """
+
+ @pytest.mark.asyncio
+ async def test_schedule_cleanup_dedupe_guard_drops_reentrant_call(self):
+ # If a cleanup task is already in flight, a second _schedule_cleanup
+ # must NOT replace it — that would cancel the in-flight teardown
+ # mid-flight and could leak the host process.
+ cli = _mock_cli_bin({'handshake': 'ok'})
+ try:
+ transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
+ await transport.connect()
+
+ slow = asyncio.create_task(asyncio.sleep(0.5))
+ transport._cleanup_task = slow
+
+ transport._schedule_cleanup(
+ SuperDocError('second', code=HOST_DISCONNECTED),
+ )
+ # Slot must still point at the original task — second call dropped.
+ assert transport._cleanup_task is slow
+
+ slow.cancel()
+ try:
+ await slow
+ except (asyncio.CancelledError, Exception):
+ pass
+ transport._cleanup_task = None
+ await transport.dispose()
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_schedule_cleanup_skipped_when_stopping(self):
+ # When `dispose()` is in progress, `_stopping` is set; the production
+ # guard inside `_schedule_cleanup` must short-circuit so a reader
+ # overflow doesn't race the graceful teardown. (Earlier iterations
+ # of this test were tautological because the test re-checked
+ # `_stopping` before calling `_schedule_cleanup`. This version calls
+ # it unconditionally and asserts the production guard fires.)
+ cli = _mock_cli_bin({'handshake': 'ok'})
+ try:
+ transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
+ await transport.connect()
+ transport._stopping = True
+ assert transport._cleanup_task is None
+
+ transport._schedule_cleanup(
+ SuperDocError('overflow', code=HOST_PROTOCOL_ERROR),
+ )
+ assert transport._cleanup_task is None
+ assert transport.state == 'CONNECTED'
+
+ transport._stopping = False
+ await transport.dispose()
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_kill_and_reset_awaits_in_flight_cleanup(self):
+ # If a reader-triggered cleanup is already running, _kill_and_reset
+ # must await it rather than spin up a parallel _cleanup that would
+ # race on _reject_all_pending and process.kill.
+ cli = _mock_cli_bin({'handshake': 'ok'})
+ try:
+ transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
+ await transport.connect()
+
+ # Replace _cleanup with a tracking stub so we can count entries
+ # and verify the second call observes the first task instead of
+ # creating a fresh one. Use Events for deterministic ordering
+ # rather than asyncio.sleep(0) (which is implementation-defined
+ # under uvloop / Python scheduling changes).
+ entry_count = 0
+ started = asyncio.Event()
+ release = asyncio.Event()
+ real_cleanup = transport._cleanup
+
+ async def tracking_cleanup(error):
+ nonlocal entry_count
+ entry_count += 1
+ started.set()
+ # First entry blocks until the test releases it; subsequent
+ # entries (if any) would race past — failure mode for the bug.
+ await release.wait()
+ await real_cleanup(error)
+
+ transport._cleanup = tracking_cleanup # type: ignore[assignment]
+
+ transport._schedule_cleanup(
+ SuperDocError('reader-overflow', code=HOST_PROTOCOL_ERROR),
+ )
+ await asyncio.wait_for(started.wait(), timeout=2.0)
+ assert entry_count == 1
+ assert transport._cleanup_task is not None
+ assert not transport._cleanup_task.done()
+
+ kill_task = asyncio.create_task(transport._kill_and_reset())
+ # Give kill_task a chance to enter — but it must NOT start a
+ # second _cleanup (which would re-fire `started`).
+ await asyncio.sleep(0.05)
+ assert entry_count == 1
+ assert not kill_task.done()
+
+ release.set()
+ await kill_task
+ assert entry_count == 1
+ assert transport.state == 'DISCONNECTED'
+ assert transport._cleanup_task is None
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_dispose_waits_for_in_flight_cleanup(self):
+ # `dispose()` called while a reader-triggered cleanup is in flight
+ # must wait for it to finish, so the caller observes "fully torn
+ # down" by the time dispose returns.
+ cli = _mock_cli_bin({'handshake': 'ok'})
+ try:
+ transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
+ await transport.connect()
+
+ started = asyncio.Event()
+ release = asyncio.Event()
+ real_cleanup = transport._cleanup
+
+ async def slow_cleanup(error):
+ started.set()
+ await release.wait()
+ await real_cleanup(error)
+
+ transport._cleanup = slow_cleanup # type: ignore[assignment]
+
+ transport._schedule_cleanup(
+ SuperDocError('reader-overflow', code=HOST_PROTOCOL_ERROR),
+ )
+ await asyncio.wait_for(started.wait(), timeout=2.0)
+ assert transport.state == 'DISPOSING'
+
+ dispose_task = asyncio.create_task(transport.dispose())
+ await asyncio.sleep(0.05)
+ # dispose must still be waiting on the cleanup task.
+ assert not dispose_task.done()
+
+ release.set()
+ await dispose_task
+ assert transport.state == 'DISCONNECTED'
+ assert transport._process is None
+ assert transport._cleanup_task is None
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_ensure_connected_drains_in_flight_cleanup_before_spawn(self):
+ # Round-3 regression: without this drain, `_start_host` reassigns
+ # `self._process` while a stale `_cleanup` task is still scheduled;
+ # the cleanup then kills the freshly-spawned process.
+ cli = _mock_cli_bin({'handshake': 'ok'})
+ try:
+ transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
+ await transport.connect()
+ old_process = transport._process
+ assert old_process is not None
+
+ started = asyncio.Event()
+ release = asyncio.Event()
+ real_cleanup = transport._cleanup
+
+ async def slow_cleanup(error):
+ started.set()
+ await release.wait()
+ await real_cleanup(error)
+
+ transport._cleanup = slow_cleanup # type: ignore[assignment]
+
+ transport._schedule_cleanup(
+ SuperDocError('reader-overflow', code=HOST_PROTOCOL_ERROR),
+ )
+ await asyncio.wait_for(started.wait(), timeout=2.0)
+
+ connect_task = asyncio.create_task(transport.connect())
+ await asyncio.sleep(0.05)
+ # connect() must be blocked on the in-flight cleanup, not racing
+ # ahead to spawn a fresh process the cleanup would then kill.
+ assert not connect_task.done()
+
+ release.set()
+ await connect_task
+ new_process = transport._process
+ assert new_process is not None
+ assert new_process is not old_process
+ # The fresh process must NOT have been killed by the stale cleanup.
+ assert new_process.returncode is None
+ assert transport.state == 'CONNECTED'
+ await transport.dispose()
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_kill_and_reset_caller_cancellation_does_not_cancel_cleanup(self):
+ # Round-3 regression: without `asyncio.shield`, cancelling the
+ # awaiter of `_kill_and_reset` propagates into the cleanup task,
+ # interrupting it mid-flight before `_process` is fully reaped and
+ # leaving state wedged in DISPOSING.
+ cli = _mock_cli_bin({'handshake': 'ok'})
+ try:
+ transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
+ await transport.connect()
+
+ started = asyncio.Event()
+ release = asyncio.Event()
+ real_cleanup = transport._cleanup
+
+ async def slow_cleanup(error):
+ started.set()
+ try:
+ await release.wait()
+ except asyncio.CancelledError:
+ # If shield works, this should NOT fire. Re-raise so the
+ # test's assertion catches the regression.
+ raise
+ await real_cleanup(error)
+
+ transport._cleanup = slow_cleanup # type: ignore[assignment]
+
+ kill_task = asyncio.create_task(transport._kill_and_reset())
+ await asyncio.wait_for(started.wait(), timeout=2.0)
+
+ kill_task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await kill_task
+
+ # Cleanup must keep running despite kill_task being cancelled.
+ assert transport._cleanup_task is not None
+ assert not transport._cleanup_task.done()
+
+ release.set()
+ await transport._cleanup_task
+ assert transport.state == 'DISCONNECTED'
+ assert transport._process is None
+ assert transport._cleanup_task is None
+ finally:
+ _cleanup_wrapper(cli)
+
+
+class TestAsyncOverflowConcurrency:
+ """Concurrency scenarios for the buffer-overflow path."""
+
+ @pytest.mark.asyncio
+ async def test_overflow_rejects_all_pending_invokes(self):
+ # Codex/Opus round-3 gap: every pending future must be rejected with
+ # HOST_PROTOCOL_ERROR — not just the one whose response overflowed.
+ # A regression where _reject_all_pending only rejects pending[msg.id]
+ # would silently leave concurrent callers hanging until watchdog.
+ big_payload = 'x' * (200 * 1024)
+ cli = _mock_cli_bin({
+ 'handshake': 'ok',
+ 'responses': [
+ {'data': {'content': big_payload}},
+ {'data': {'v': 2}},
+ {'data': {'v': 3}},
+ ],
+ })
+ try:
+ transport = AsyncHostTransport(
+ cli,
+ startup_timeout_ms=5_000,
+ stdout_buffer_limit_bytes=64 * 1024,
+ watchdog_timeout_ms=10_000,
+ )
+ await transport.connect()
+ tasks = [
+ asyncio.ensure_future(transport.invoke(_TEST_OP, {'query': f'q{i}'}))
+ for i in range(3)
+ ]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ assert all(isinstance(r, SuperDocError) for r in results), results
+ assert all(r.code == HOST_PROTOCOL_ERROR for r in results)
+ # Every error must carry the actionable hint, not just the first.
+ assert all('stdout_buffer_limit_bytes' in str(r) for r in results)
+ assert transport._pending == {}
+ assert transport.state == 'DISCONNECTED'
+ await transport.dispose()
+ finally:
+ _cleanup_wrapper(cli)
+
+ @pytest.mark.asyncio
+ async def test_reconnect_after_buffer_overflow(self):
+ # Sync transport has test_reconnect_after_failure; async previously
+ # only had reconnect-after-explicit-dispose. After reader-triggered
+ # cleanup the transport must be reusable for a fresh invoke without
+ # leaving _cleanup_task / _connecting / _process in a wedged state.
+ cli1 = _mock_cli_bin({
+ 'handshake': 'ok',
+ 'responses': [{'data': {'content': 'x' * (200 * 1024)}}],
+ })
+ transport = None
+ try:
+ transport = AsyncHostTransport(
+ cli1,
+ startup_timeout_ms=5_000,
+ stdout_buffer_limit_bytes=64 * 1024,
+ )
+ await transport.connect()
+ with pytest.raises(SuperDocError) as exc_info:
+ await transport.invoke(_TEST_OP, {'query': 'big'})
+ assert exc_info.value.code == HOST_PROTOCOL_ERROR
+ cleanup_task = transport._cleanup_task
+ if cleanup_task is not None:
+ await cleanup_task
+ assert transport.state == 'DISCONNECTED'
+ assert transport._cleanup_task is None
+ finally:
+ _cleanup_wrapper(cli1)
+
+ cli2 = _mock_cli_bin({
+ 'handshake': 'ok',
+ 'responses': [{'data': {'v': 'reconnected'}}],
+ })
+ try:
+ # Reuse the transport — point at a healthy host with default buffer.
+ transport._cli_bin = cli2
+ transport._stdout_buffer_limit_bytes = DEFAULT_STDOUT_BUFFER_LIMIT_BYTES
+ result = await transport.invoke(_TEST_OP, {'query': 'again'})
+ assert result == {'v': 'reconnected'}
+ assert transport.state == 'CONNECTED'
+ await transport.dispose()
+ finally:
+ _cleanup_wrapper(cli2)
diff --git a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue
index 53dd19da12..83a761b27a 100644
--- a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue
+++ b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue
@@ -1,5 +1,7 @@