diff --git a/devtools/visual-testing/pnpm-lock.yaml b/devtools/visual-testing/pnpm-lock.yaml index 71f5c412fb..ad5badda7a 100644 --- a/devtools/visual-testing/pnpm-lock.yaml +++ b/devtools/visual-testing/pnpm-lock.yaml @@ -273,16 +273,6 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@css-render/plugin-bem@0.15.14': - resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} - peerDependencies: - css-render: ~0.15.14 - - '@css-render/vue3-ssr@0.15.14': - resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==} - peerDependencies: - vue: ^3.0.11 - '@csstools/color-helpers@6.0.1': resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} engines: {node: '>=20.19.0'} @@ -314,9 +304,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@emotion/hash@0.8.0': - resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -715,9 +702,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@juggle/resize-observer@3.4.0': - resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - '@lifeomic/attempt@3.1.0': resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==} @@ -1101,15 +1085,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/katex@0.16.8': - resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} - - '@types/lodash-es@4.17.12': - resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - - '@types/lodash@4.17.23': - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} - '@types/minimatch@6.0.0': resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. @@ -1268,9 +1243,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - async-validator@4.2.5: - resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1343,9 +1315,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-render@0.15.14: - resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} - css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1354,9 +1323,6 @@ packages: resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} engines: {node: '>=20'} - csstype@3.0.11: - resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1364,14 +1330,6 @@ packages: resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} engines: {node: '>=20'} - date-fns-tz@3.2.0: - resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} - peerDependencies: - date-fns: ^3.0.0 || ^4.0.0 - - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1498,9 +1456,6 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - evtd@0.2.4: - resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1612,10 +1567,6 @@ packages: has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} - engines: {node: '>=12.0.0'} - html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1731,15 +1682,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.23: - resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1796,11 +1741,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - naive-ui@2.43.2: - resolution: {integrity: sha512-YlLMnGrwGTOc+zMj90sG3ubaH5/7czsgLgGcjTLA981IUaz8r6t4WIujNt8r9PNr+dqv6XNEr0vxkARgPPjfBQ==} - peerDependencies: - vue: ^3.0.0 - nan@2.25.0: resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} @@ -1998,9 +1938,6 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - seemly@0.3.10: - resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2069,8 +2006,8 @@ packages: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} superdoc@file:../../packages/superdoc/superdoc.tgz: - resolution: {integrity: sha512-zkLAqTj0ghbPnHFJNtIH5r8H0CV69eWf+V4rQRC8cig3sqBuLDb29O8lCMC4wsP5msUt8HqIRW52Zr6l9CfHtw==, tarball: file:../../packages/superdoc/superdoc.tgz} - version: 1.18.0 + resolution: {integrity: sha512-a82JOO6x1ajl1QakVdhen1WoCUg7jpjP6lA+pup0tvLvOwHQqhYamfCCxhMaanlkIVM3P2D3E2NL2ZfmJZRpnA==, tarball: file:../../packages/superdoc/superdoc.tgz} + version: 1.20.0 peerDependencies: '@hocuspocus/provider': ^2.13.6 pdfjs-dist: ^5.4.296 @@ -2133,9 +2070,6 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - treemate@0.3.11: - resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2170,11 +2104,6 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true - vdirs@0.1.8: - resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} - peerDependencies: - vue: ^3.0.11 - vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2248,11 +2177,6 @@ packages: jsdom: optional: true - vooks@0.2.12: - resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} - peerDependencies: - vue: ^3.0.0 - vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} @@ -2280,11 +2204,6 @@ packages: typescript: optional: true - vueuc@0.4.65: - resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==} - peerDependencies: - vue: ^3.0.11 - w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -2911,14 +2830,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': - dependencies: - css-render: 0.15.14 - - '@css-render/vue3-ssr@0.15.14(vue@3.5.25(typescript@5.9.3))': - dependencies: - vue: 3.5.25(typescript@5.9.3) - '@csstools/color-helpers@6.0.1': {} '@csstools/css-calc@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -2941,8 +2852,6 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@emotion/hash@0.8.0': {} - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -3184,8 +3093,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@juggle/resize-observer@3.4.0': {} - '@lifeomic/attempt@3.1.0': {} '@mapbox/node-pre-gyp@1.0.11': @@ -3653,14 +3560,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/katex@0.16.8': {} - - '@types/lodash-es@4.17.12': - dependencies: - '@types/lodash': 4.17.23 - - '@types/lodash@4.17.23': {} - '@types/minimatch@6.0.0': dependencies: minimatch: 10.1.2 @@ -3877,8 +3776,6 @@ snapshots: assertion-error@2.0.1: {} - async-validator@4.2.5: {} - balanced-match@1.0.2: {} bidi-js@1.0.3: @@ -3952,11 +3849,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-render@0.15.14: - dependencies: - '@emotion/hash': 0.8.0 - csstype: 3.0.11 - css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -3969,8 +3861,6 @@ snapshots: css-tree: 3.1.0 lru-cache: 11.2.5 - csstype@3.0.11: {} - csstype@3.2.3: {} data-urls@6.0.1: @@ -3978,12 +3868,6 @@ snapshots: whatwg-mimetype: 5.0.0 whatwg-url: 15.1.0 - date-fns-tz@3.2.0(date-fns@4.1.0): - dependencies: - date-fns: 4.1.0 - - date-fns@4.1.0: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -4170,8 +4054,6 @@ snapshots: eventemitter3@5.0.4: {} - evtd@0.2.4: {} - expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -4293,8 +4175,6 @@ snapshots: has-unicode@2.0.1: optional: true - highlight.js@11.11.1: {} - html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.11.0 @@ -4423,12 +4303,8 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.23: {} - lodash.merge@4.6.2: {} - lodash@4.17.23: {} - loupe@3.2.1: {} lru-cache@11.2.5: {} @@ -4481,29 +4357,6 @@ snapshots: ms@2.1.3: {} - naive-ui@2.43.2(vue@3.5.25(typescript@5.9.3)): - dependencies: - '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) - '@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.9.3)) - '@types/katex': 0.16.8 - '@types/lodash': 4.17.23 - '@types/lodash-es': 4.17.12 - async-validator: 4.2.5 - css-render: 0.15.14 - csstype: 3.2.3 - date-fns: 4.1.0 - date-fns-tz: 3.2.0(date-fns@4.1.0) - evtd: 0.2.4 - highlight.js: 11.11.1 - lodash: 4.17.23 - lodash-es: 4.17.23 - seemly: 0.3.10 - treemate: 0.3.11 - vdirs: 0.1.8(vue@3.5.25(typescript@5.9.3)) - vooks: 0.2.12(vue@3.5.25(typescript@5.9.3)) - vue: 3.5.25(typescript@5.9.3) - vueuc: 0.4.65(vue@3.5.25(typescript@5.9.3)) - nan@2.25.0: optional: true @@ -4718,8 +4571,6 @@ snapshots: dependencies: xmlchars: 2.2.0 - seemly@0.3.10: {} - semver@6.3.1: optional: true @@ -4790,7 +4641,6 @@ snapshots: eventemitter3: 5.0.4 jsdom: 27.4.0 konva: 10.2.0 - naive-ui: 2.43.2(vue@3.5.25(typescript@5.9.3)) pdfjs-dist: 4.6.82 pinia: 2.3.1(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)) rollup-plugin-copy: 3.5.0 @@ -4860,8 +4710,6 @@ snapshots: dependencies: punycode: 2.3.1 - treemate@0.3.11: {} - tslib@2.8.1: {} tsx@4.21.0: @@ -4890,11 +4738,6 @@ snapshots: uuid@9.0.1: {} - vdirs@0.1.8(vue@3.5.25(typescript@5.9.3)): - dependencies: - evtd: 0.2.4 - vue: 3.5.25(typescript@5.9.3) - vite-node@3.2.4(@types/node@22.19.9)(tsx@4.21.0): dependencies: cac: 6.7.14 @@ -4971,11 +4814,6 @@ snapshots: - tsx - yaml - vooks@0.2.12(vue@3.5.25(typescript@5.9.3)): - dependencies: - evtd: 0.2.4 - vue: 3.5.25(typescript@5.9.3) - vue-demi@0.14.10(vue@3.5.25(typescript@5.9.3)): dependencies: vue: 3.5.25(typescript@5.9.3) @@ -5000,17 +4838,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - vueuc@0.4.65(vue@3.5.25(typescript@5.9.3)): - dependencies: - '@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.9.3)) - '@juggle/resize-observer': 3.4.0 - css-render: 0.15.14 - evtd: 0.2.4 - seemly: 0.3.10 - vdirs: 0.1.8(vue@3.5.25(typescript@5.9.3)) - vooks: 0.2.12(vue@3.5.25(typescript@5.9.3)) - vue: 3.5.25(typescript@5.9.3) - w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 235a726aef..bec84922bf 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -4134,8 +4134,7 @@ describe('requirePageBoundary edge cases', () => { expect(pageContainsBlock(layout.pages[1], 'body')).toBe(true); }); - it('suppresses inter-paragraph spacing when current paragraph has contextualSpacing', () => { - // Test that current paragraph's spacingAfter is suppressed when it has contextualSpacing + it('suppresses inter-paragraph spacing when both paragraphs have contextualSpacing', () => { const current: FlowBlock = { kind: 'paragraph', id: 'current', @@ -4143,8 +4142,8 @@ describe('requirePageBoundary edge cases', () => { attrs: { keepNext: true, styleId: 'TestStyle', - contextualSpacing: true, // Current has it - spacing: { after: 50 }, // Large spacing after + contextualSpacing: true, + spacing: { after: 50 }, }, }; const next: FlowBlock = { @@ -4152,8 +4151,8 @@ describe('requirePageBoundary edge cases', () => { id: 'next', runs: [{ text: 'Next', fontFamily: 'Arial', fontSize: 12 }], attrs: { - styleId: 'TestStyle', // Same style - // Note: next does NOT have contextualSpacing + styleId: 'TestStyle', + contextualSpacing: true, spacing: { before: 10 }, }, }; @@ -4169,10 +4168,7 @@ describe('requirePageBoundary edge cases', () => { totalHeight: 20, }; - // If contextual spacing works: gap = max(0, 10) = 10px (current's after suppressed) - // Total = 30 + 10 + 20 = 60px - // If broken: gap = max(50, 10) = 50px - // Total = 30 + 50 + 20 = 100px + // Both opt in → gap = max(0, 0) = 0px. Total = 30 + 0 + 20 = 50px const options: LayoutOptions = { pageSize: { w: 400, h: 130 }, margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 70px content @@ -4180,11 +4176,57 @@ describe('requirePageBoundary edge cases', () => { const layout = layoutDocument([current, next], [currentMeasure, nextMeasure], options); - // Should fit on one page (60px < 70px) expect(layout.pages).toHaveLength(1); expect(pageContainsBlock(layout.pages[0], 'current')).toBe(true); expect(pageContainsBlock(layout.pages[0], 'next')).toBe(true); }); + + it('suppresses current after-spacing even when next does not have contextualSpacing (per-paragraph)', () => { + const current: FlowBlock = { + kind: 'paragraph', + id: 'current', + runs: [{ text: 'Current', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + keepNext: true, + styleId: 'TestStyle', + contextualSpacing: true, + spacing: { after: 50 }, + }, + }; + const next: FlowBlock = { + kind: 'paragraph', + id: 'next', + runs: [{ text: 'Next', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + styleId: 'TestStyle', + // next does NOT have contextualSpacing — per-paragraph rule: current still + // suppresses its own after-spacing independently + spacing: { before: 10 }, + }, + }; + + const currentMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [makeLine(30)], + totalHeight: 30, + }; + const nextMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [makeLine(20)], + totalHeight: 20, + }; + + // Current suppresses its own after → 0. Next does not suppress before → 10. + // gap = max(0, 10) = 10px. Total = 30 + 10 + 20 = 60px < 70px → one page + const options: LayoutOptions = { + pageSize: { w: 400, h: 130 }, + margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 70px content + }; + + const layout = layoutDocument([current, next], [currentMeasure, nextMeasure], options); + + expect(layout.pages).toHaveLength(1); + }); }); /** @@ -4578,14 +4620,14 @@ describe('requirePageBoundary edge cases', () => { expect(pageContainsBlock(layout.pages[0], 'body')).toBe(true); }); - it('reclaims trailing spacing when chain starter has contextualSpacing', () => { - // Previous paragraph has spacingAfter, chain starter has contextualSpacing + same style. + it('reclaims trailing spacing when both filler and chain starter have contextualSpacing', () => { + // Both filler and chain starter have contextualSpacing + same style. // The trailing spacing should be reclaimed, making room for the chain. const filler: FlowBlock = { kind: 'paragraph', id: 'filler', runs: [{ text: 'Filler content', fontFamily: 'Arial', fontSize: 12 }], - attrs: { styleId: 'Normal', spacingAfter: 10 }, + attrs: { styleId: 'Normal', contextualSpacing: true, spacing: { after: 10 } }, }; const chainStarter: FlowBlock = { kind: 'paragraph', @@ -4600,7 +4642,7 @@ describe('requirePageBoundary edge cases', () => { attrs: {}, }; - // Filler is 40px, chain starter and anchor are each 25px + // Filler is 40px, chain starter and anchor are each 26px const fillerMeasure: ParagraphMeasure = { kind: 'paragraph', lines: [makeLine(40)], @@ -4608,15 +4650,15 @@ describe('requirePageBoundary edge cases', () => { }; const chainMeasure: ParagraphMeasure = { kind: 'paragraph', - lines: [makeLine(25)], - totalHeight: 25, + lines: [makeLine(26)], + totalHeight: 26, }; // Page has 100px content area - // After filler (40px) + spacingAfter (10px), cursor is at 50px from top + // After filler (40px) + spacingAfter (10px), cursor is at 80px (top=30 + 40 + 10) // Available without reclaim: 100 - 50 = 50px - // Chain needs: 25 + 25 = 50px (exactly fits with reclaim, doesn't fit without) - // With contextualSpacing, the 10px spacingAfter is reclaimed → 60px available + // Chain needs: 26 + 26 = 52px > 50px (does NOT fit without reclaim) + // With reclaim the 10px spacingAfter is recovered → 60px available, 52px fits. const options: LayoutOptions = { pageSize: { w: 400, h: 160 }, margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 100px content @@ -4635,6 +4677,58 @@ describe('requirePageBoundary edge cases', () => { expect(pageContainsBlock(layout.pages[0], 'anchor')).toBe(true); }); + it('does not reclaim trailing spacing when only chain starter has contextualSpacing', () => { + // Filler does NOT have contextualSpacing — per-paragraph rule: filler does not suppress its own after. + // Same dimensions as the positive case: chain = 52px, available without reclaim = 50px. + // Without reclaim 52 > 50, so the chain moves to page 2. + const filler: FlowBlock = { + kind: 'paragraph', + id: 'filler', + runs: [{ text: 'Filler content', fontFamily: 'Arial', fontSize: 12 }], + attrs: { styleId: 'Normal', spacing: { after: 10 } }, + }; + const chainStarter: FlowBlock = { + kind: 'paragraph', + id: 'chainStarter', + runs: [{ text: 'Chain starter', fontFamily: 'Arial', fontSize: 12 }], + attrs: { keepNext: true, contextualSpacing: true, styleId: 'Normal' }, + }; + const anchor: FlowBlock = { + kind: 'paragraph', + id: 'anchor', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 12 }], + attrs: {}, + }; + + const fillerMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [makeLine(40)], + totalHeight: 40, + }; + const chainMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [makeLine(26)], + totalHeight: 26, + }; + + const options: LayoutOptions = { + pageSize: { w: 400, h: 160 }, + margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 100px content + }; + + const layout = layoutDocument( + [filler, chainStarter, anchor], + [fillerMeasure, chainMeasure, chainMeasure], + options, + ); + + // No reclaim → 50px available, 52px chain → page 2 + expect(layout.pages).toHaveLength(2); + expect(pageContainsBlock(layout.pages[0], 'filler')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'chainStarter')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'anchor')).toBe(true); + }); + it('does not reclaim trailing spacing when styles differ', () => { // Previous paragraph has spacingAfter, chain starter has contextualSpacing but DIFFERENT style. // The trailing spacing should NOT be reclaimed. @@ -4690,5 +4784,66 @@ describe('requirePageBoundary edge cases', () => { expect(pageContainsBlock(layout.pages[1], 'chainStarter')).toBe(true); expect(pageContainsBlock(layout.pages[1], 'anchor')).toBe(true); }); + + it('does not suppress chain-internal spacing for mixed contextualSpacing', () => { + // Three same-style paragraphs in a keepNext chain: true / false / true. + // The middle one opts out, so spacing around it should NOT be suppressed. + const filler: FlowBlock = { + kind: 'paragraph', + id: 'filler', + runs: [{ text: 'Filler', fontFamily: 'Arial', fontSize: 12 }], + attrs: { styleId: 'Other' }, + }; + const para1: FlowBlock = { + kind: 'paragraph', + id: 'para1', + runs: [{ text: 'Para 1', fontFamily: 'Arial', fontSize: 12 }], + attrs: { keepNext: true, styleId: 'Normal', contextualSpacing: true, spacing: { after: 20 } }, + }; + const para2: FlowBlock = { + kind: 'paragraph', + id: 'para2', + runs: [{ text: 'Para 2', fontFamily: 'Arial', fontSize: 12 }], + attrs: { keepNext: true, styleId: 'Normal', contextualSpacing: false, spacing: { before: 20, after: 20 } }, + }; + const para3: FlowBlock = { + kind: 'paragraph', + id: 'para3', + runs: [{ text: 'Para 3', fontFamily: 'Arial', fontSize: 12 }], + attrs: { styleId: 'Normal', contextualSpacing: true, spacing: { before: 20 } }, + }; + + const fillerMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [makeLine(10)], + totalHeight: 10, + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [makeLine(20)], + totalHeight: 20, + }; + + // Chain (para1+para2+para3) with per-paragraph rule: + // para1→para2: para1 suppresses after (cs=true) → 0, para2 keeps before (cs=false) → 20. gap = max(0,20) = 20 + // para2→para3: para2 keeps after (cs=false) → 20, para3 suppresses before (cs=true) → 0. gap = max(20,0) = 20 + // Total: 20 + 20 + 20 + 20 + 20 = 100px + // + // Filler takes 10px. Content area = 105px. + // After filler, 95px remain — 100px chain doesn't fit current page but fits blank page → page 2. + const options: LayoutOptions = { + pageSize: { w: 400, h: 165 }, + margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 105px content + }; + + const layout = layoutDocument([filler, para1, para2, para3], [fillerMeasure, measure, measure, measure], options); + + // Chain must move to page 2 because it's 100px and only 95px remain after filler. + expect(layout.pages).toHaveLength(2); + expect(pageContainsBlock(layout.pages[0], 'filler')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'para1')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'para2')).toBe(true); + expect(pageContainsBlock(layout.pages[1], 'para3')).toBe(true); + }); }); }); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 9b33fae0f9..eda93d1e32 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -43,7 +43,7 @@ import { layoutTableBlock, createAnchoredTableFragment, ANCHORED_TABLE_FULL_WIDT import { collectAnchoredDrawings, collectAnchoredTables, collectPreRegisteredAnchors } from './anchors.js'; import { createPaginator, type PageState, type ConstraintBoundary } from './paginator.js'; import { formatPageNumber } from './pageNumbering.js'; -import { shouldSuppressSpacingForEmpty } from './layout-utils.js'; +import { shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js'; import { balancePageColumns } from './column-balancing.js'; import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js'; import { cloneColumnLayout, widthsEqual } from './column-utils.js'; @@ -356,23 +356,31 @@ function calculateChainHeight( // (which is tracked in PageState from the previous layout operation) const prevTrailing = Number.isFinite(state.trailingSpacing) && state.trailingSpacing > 0 ? state.trailingSpacing : 0; - const sameAsLastOnPage = styleId && state.lastParagraphStyleId === styleId; - - // Apply contextual spacing suppression if this paragraph has it AND matches previous style - const effectiveSpacingBefore = - contextualSpacing && sameAsLastOnPage ? 0 : Math.max(spacingBefore - prevTrailing, 0); + // Per-paragraph contextual spacing: each side independently suppresses its own spacing + const prevSuppressAfter = shouldSuppressOwnSpacing( + state.lastParagraphStyleId, + state.lastParagraphContextualSpacing, + styleId, + ); + const currSuppressBefore = shouldSuppressOwnSpacing(styleId, contextualSpacing, state.lastParagraphStyleId); + let effectiveSpacingBefore: number; + if (prevSuppressAfter && currSuppressBefore) { + effectiveSpacingBefore = 0; + } else if (prevSuppressAfter) { + effectiveSpacingBefore = spacingBefore; + } else if (currSuppressBefore) { + effectiveSpacingBefore = 0; + } else { + effectiveSpacingBefore = Math.max(spacingBefore - prevTrailing, 0); + } totalHeight += effectiveSpacingBefore; isFirstMember = false; } else { - // Subsequent chain members: calculate inter-paragraph spacing within the chain - const sameStyle = styleId && prevStyleId && styleId === prevStyleId; - - // OOXML spacing rules: - // 1. If previous paragraph has contextualSpacing AND styles match → suppress its spacingAfter - // 2. If current paragraph has contextualSpacing AND styles match → suppress its spacingBefore - // 3. Resulting gap = max(effective spacingAfter, effective spacingBefore) - const effectiveSpacingAfterPrev = prevContextualSpacing && sameStyle ? 0 : prevSpacingAfter; - const effectiveSpacingBefore = contextualSpacing && sameStyle ? 0 : spacingBefore; + // Subsequent chain members: per-paragraph contextual spacing + const prevSuppressAfter = shouldSuppressOwnSpacing(prevStyleId, prevContextualSpacing, styleId); + const currSuppressBefore = shouldSuppressOwnSpacing(styleId, contextualSpacing, prevStyleId); + const effectiveSpacingAfterPrev = prevSuppressAfter ? 0 : prevSpacingAfter; + const effectiveSpacingBefore = currSuppressBefore ? 0 : spacingBefore; const interParagraphSpacing = Math.max(effectiveSpacingAfterPrev, effectiveSpacingBefore); totalHeight += interParagraphSpacing; } @@ -403,9 +411,10 @@ function calculateChainHeight( : undefined; const anchorContextualSpacing = (anchorBlock as ParagraphBlock).attrs?.contextualSpacing === true; - const sameStyle = anchorStyleId && prevStyleId && anchorStyleId === prevStyleId; - const effectiveSpacingAfterPrev = prevContextualSpacing && sameStyle ? 0 : prevSpacingAfter; - const effectiveAnchorSpacingBefore = anchorContextualSpacing && sameStyle ? 0 : anchorSpacingBefore; + const prevSuppressAfter = shouldSuppressOwnSpacing(prevStyleId, prevContextualSpacing, anchorStyleId); + const anchorSuppressBefore = shouldSuppressOwnSpacing(anchorStyleId, anchorContextualSpacing, prevStyleId); + const effectiveSpacingAfterPrev = prevSuppressAfter ? 0 : prevSpacingAfter; + const effectiveAnchorSpacingBefore = anchorSuppressBefore ? 0 : anchorSpacingBefore; const interParagraphSpacing = Math.max(effectiveSpacingAfterPrev, effectiveAnchorSpacingBefore); // Optimization (SD-1282): Only require space for anchor's first line, not full height. @@ -1810,12 +1819,15 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const firstMemberBlock = blocks[chain.startIndex] as ParagraphBlock; const firstMemberStyleId = typeof firstMemberBlock.attrs?.styleId === 'string' ? firstMemberBlock.attrs?.styleId : undefined; - const firstMemberContextualSpacing = firstMemberBlock.attrs?.contextualSpacing === true; - const contextualSpacingApplies = - firstMemberContextualSpacing && firstMemberStyleId && state.lastParagraphStyleId === firstMemberStyleId; + // Reclaim depends on whether the previous paragraph suppresses its own after-spacing + const prevSuppressAfter = shouldSuppressOwnSpacing( + state.lastParagraphStyleId, + state.lastParagraphContextualSpacing, + firstMemberStyleId, + ); const prevTrailing = Number.isFinite(state.trailingSpacing) && state.trailingSpacing > 0 ? state.trailingSpacing : 0; - const effectiveAvailableHeight = contextualSpacingApplies ? availableHeight + prevTrailing : availableHeight; + const effectiveAvailableHeight = prevSuppressAfter ? availableHeight + prevTrailing : availableHeight; const chainHeight = calculateChainHeight(chain, blocks, measures, state); @@ -1852,9 +1864,27 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options Number.isFinite(state.trailingSpacing) && state.trailingSpacing > 0 ? state.trailingSpacing : 0; const currentStyleId = typeof paraBlock.attrs?.styleId === 'string' ? paraBlock.attrs?.styleId : undefined; const currentContextualSpacing = asBoolean(paraBlock.attrs?.contextualSpacing); - const contextualSpacingApplies = - currentContextualSpacing && currentStyleId && state.lastParagraphStyleId === currentStyleId; - const effectiveSpacingBefore = contextualSpacingApplies ? 0 : Math.max(spacingBefore - prevTrailing, 0); + // Per-paragraph: each side independently suppresses its own spacing + const prevSuppressAfter = shouldSuppressOwnSpacing( + state.lastParagraphStyleId, + state.lastParagraphContextualSpacing, + currentStyleId, + ); + const currSuppressBefore = shouldSuppressOwnSpacing( + currentStyleId, + currentContextualSpacing, + state.lastParagraphStyleId, + ); + let effectiveSpacingBefore: number; + if (prevSuppressAfter && currSuppressBefore) { + effectiveSpacingBefore = 0; + } else if (prevSuppressAfter) { + effectiveSpacingBefore = spacingBefore; + } else if (currSuppressBefore) { + effectiveSpacingBefore = 0; + } else { + effectiveSpacingBefore = Math.max(spacingBefore - prevTrailing, 0); + } const currentHeight = getMeasureHeight(paraBlock, measure); const nextHeight = getMeasureHeight(nextBlock, nextMeasure); @@ -1864,9 +1894,11 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options nextIsParagraph && typeof nextBlock.attrs?.styleId === 'string' ? nextBlock.attrs?.styleId : undefined; const nextContextualSpacing = nextIsParagraph && asBoolean(nextBlock.attrs?.contextualSpacing); - const sameStyleAsNext = currentStyleId && nextStyleId && nextStyleId === currentStyleId; - const effectiveSpacingAfter = currentContextualSpacing && sameStyleAsNext ? 0 : spacingAfter; - const effectiveNextSpacingBefore = nextContextualSpacing && sameStyleAsNext ? 0 : nextSpacingBefore; + const currSuppressAfter = shouldSuppressOwnSpacing(currentStyleId, currentContextualSpacing, nextStyleId); + const nextSuppressBefore = + nextIsParagraph && shouldSuppressOwnSpacing(nextStyleId, nextContextualSpacing, currentStyleId); + const effectiveSpacingAfter = currSuppressAfter ? 0 : spacingAfter; + const effectiveNextSpacingBefore = nextSuppressBefore ? 0 : nextSpacingBefore; const interParagraphSpacing = nextIsParagraph ? Math.max(effectiveSpacingAfter, effectiveNextSpacingBefore) : effectiveSpacingAfter; @@ -1886,9 +1918,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options ? effectiveSpacingBefore + currentHeight + interParagraphSpacing + nextFirstLineHeight : effectiveSpacingBefore + currentHeight + spacingAfter + nextHeight; - const effectiveAvailableHeight = contextualSpacingApplies - ? availableHeight + prevTrailing - : availableHeight; + const effectiveAvailableHeight = prevSuppressAfter ? availableHeight + prevTrailing : availableHeight; if (combinedHeight > effectiveAvailableHeight && state.page.fragments.length > 0) { state = paginator.advanceColumn(state); } @@ -1898,8 +1928,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options /** * Contextual spacing suppression for spacingAfter. - * Per OOXML spec: when current paragraph has contextualSpacing=true and - * the next paragraph has the same styleId, suppress current's spacingAfter. + * Per-paragraph: current paragraph suppresses its own after-spacing when + * it has contextualSpacing and the next paragraph shares the same styleId. */ let overrideSpacingAfter: number | undefined; const curStyleId = typeof paraBlock.attrs?.styleId === 'string' ? paraBlock.attrs.styleId : undefined; @@ -1907,11 +1937,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (curContextualSpacing && curStyleId) { const nextBlock = index < blocks.length - 1 ? blocks[index + 1] : null; if (nextBlock?.kind === 'paragraph') { - const nextStyleId = - typeof (nextBlock as ParagraphBlock).attrs?.styleId === 'string' - ? (nextBlock as ParagraphBlock).attrs?.styleId - : undefined; - if (nextStyleId === curStyleId) { + const nextPara = nextBlock as ParagraphBlock; + const nextStyleId = typeof nextPara.attrs?.styleId === 'string' ? nextPara.attrs?.styleId : undefined; + if (shouldSuppressOwnSpacing(curStyleId, curContextualSpacing, nextStyleId)) { overrideSpacingAfter = 0; } } 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 7f2e3623a4..cf7473edd6 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts @@ -59,6 +59,7 @@ const makePageState = (): PageState => ({ activeConstraintIndex: -1, trailingSpacing: 0, lastParagraphStyleId: undefined, + lastParagraphContextualSpacing: false, }); /** @@ -553,9 +554,10 @@ describe('layoutParagraphBlock - remeasurement with list markers', () => { describe('layoutParagraphBlock - contextualSpacing', () => { describe('same-style paragraphs', () => { - it('suppresses spacingBefore when same-style paragraphs are adjacent', () => { + it('suppresses spacingBefore when both same-style paragraphs opt in', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Heading1'; + pageState.lastParagraphContextualSpacing = true; pageState.trailingSpacing = 20; pageState.cursorY = 100; @@ -601,6 +603,7 @@ describe('layoutParagraphBlock - contextualSpacing', () => { it('undoes previous paragraph trailing spacing when contextualSpacing is active', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; pageState.trailingSpacing = 15; pageState.cursorY = 100; @@ -647,6 +650,7 @@ describe('layoutParagraphBlock - contextualSpacing', () => { it('handles contextualSpacing when trailingSpacing is 0', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; pageState.trailingSpacing = 0; pageState.cursorY = 100; @@ -693,6 +697,7 @@ describe('layoutParagraphBlock - contextualSpacing', () => { it('handles contextualSpacing when trailingSpacing is null', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; // eslint-disable-next-line @typescript-eslint/no-explicit-any (pageState.trailingSpacing as any) = null; pageState.cursorY = 100; @@ -735,6 +740,7 @@ describe('layoutParagraphBlock - contextualSpacing', () => { it('handles contextualSpacing when trailingSpacing is undefined', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; pageState.trailingSpacing = 0; pageState.cursorY = 100; @@ -994,6 +1000,7 @@ describe('layoutParagraphBlock - contextualSpacing', () => { it('handles NaN trailingSpacing gracefully', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; pageState.trailingSpacing = NaN; pageState.cursorY = 100; @@ -1035,6 +1042,7 @@ describe('layoutParagraphBlock - contextualSpacing', () => { it('handles Infinity trailingSpacing gracefully', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; pageState.trailingSpacing = Infinity; pageState.cursorY = 100; @@ -1076,6 +1084,7 @@ describe('layoutParagraphBlock - contextualSpacing', () => { it('handles negative trailingSpacing gracefully', () => { const pageState = makePageState(); pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; pageState.trailingSpacing = -10; pageState.cursorY = 100; @@ -1114,6 +1123,123 @@ describe('layoutParagraphBlock - contextualSpacing', () => { expect(pageState.cursorY).toBe(130); }); }); + + describe('per-paragraph contextual spacing', () => { + it('suppresses only previous after when previous has contextualSpacing but current does not', () => { + const pageState = makePageState(); + pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = true; + pageState.trailingSpacing = 20; + pageState.cursorY = 100; + + const ensurePage = vi.fn(() => pageState); + + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-block', + runs: [{ text: 'Test', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + styleId: 'Normal', + contextualSpacing: false, + spacing: { before: 30, after: 10 }, + }, + }; + + const measure = makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 150 }]); + + const ctx: ParagraphLayoutContext = { + block, + measure, + columnWidth: 150, + ensurePage, + advanceColumn: vi.fn((state) => state), + columnX: vi.fn(() => 50), + floatManager: makeFloatManager(), + }; + + layoutParagraphBlock(ctx); + + // Previous suppresses its own after → rewind trailing (100 - 20 = 80), trailingSpacing = 0. + // Current does NOT suppress its own before → spacingBefore (30) stays. + // Collapse: max(30 - 0, 0) = 30. cursorY = 80 + 30 + 20 + 10 = 140 + expect(pageState.cursorY).toBe(140); + }); + + it('suppresses only current before when current has contextualSpacing but previous does not', () => { + const pageState = makePageState(); + pageState.lastParagraphStyleId = 'Normal'; + pageState.lastParagraphContextualSpacing = false; + pageState.trailingSpacing = 20; + pageState.cursorY = 100; + + const ensurePage = vi.fn(() => pageState); + + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-block', + runs: [{ text: 'Test', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + styleId: 'Normal', + contextualSpacing: true, + spacing: { before: 30, after: 10 }, + }, + }; + + const measure = makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 150 }]); + + const ctx: ParagraphLayoutContext = { + block, + measure, + columnWidth: 150, + ensurePage, + advanceColumn: vi.fn((state) => state), + columnX: vi.fn(() => 50), + floatManager: makeFloatManager(), + }; + + layoutParagraphBlock(ctx); + + // Previous does NOT suppress its own after → no rewind (trailingSpacing stays 20). + // Current suppresses its own before → spacingBefore = 0. + // Collapse: max(0 - 20, 0) = 0. cursorY = 100 + 0 + 20 + 10 = 130 + expect(pageState.cursorY).toBe(130); + }); + + it('persists contextualSpacing from positioned-frame early return', () => { + const pageState = makePageState(); + pageState.cursorY = 100; + + const ensurePage = vi.fn(() => pageState); + + // A positioned-frame paragraph with contextualSpacing=true + const frameBlock: ParagraphBlock = { + kind: 'paragraph', + id: 'frame-block', + runs: [{ text: 'Frame', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + styleId: 'Normal', + contextualSpacing: true, + frame: { wrap: 'none' }, + }, + }; + + const measure = makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 150 }]); + + layoutParagraphBlock({ + block: frameBlock, + measure, + columnWidth: 150, + ensurePage, + advanceColumn: vi.fn((state) => state), + columnX: vi.fn(() => 50), + floatManager: makeFloatManager(), + }); + + // After the positioned-frame early return, page state should carry the flag + expect(pageState.lastParagraphStyleId).toBe('Normal'); + expect(pageState.lastParagraphContextualSpacing).toBe(true); + }); + }); }); describe('layoutParagraphBlock - keepLines', () => { diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index 4b91b15e78..45676f50f2 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -19,6 +19,7 @@ import { sliceLines, extractBlockPmRange, isEmptyTextParagraph, + shouldSuppressOwnSpacing, } from './layout-utils.js'; import { computeAnchorX } from './floating-objects.js'; import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js'; @@ -529,6 +530,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para state.page.fragments.push(fragment); state.trailingSpacing = 0; state.lastParagraphStyleId = styleId; + state.lastParagraphContextualSpacing = contextualSpacing; return; } @@ -595,26 +597,26 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para /** * Contextual Spacing Logic (OOXML w:contextualSpacing) * - * When contextualSpacing is enabled on a paragraph, spacing before and after is - * suppressed when the paragraph is adjacent to another paragraph with the same style. + * Each paragraph independently decides whether to suppress its own spacing. + * A paragraph suppresses its before/after spacing when it has contextualSpacing + * enabled and the adjacent paragraph shares the same style. The adjacent + * paragraph's contextualSpacing flag is NOT consulted. * - * This implements Microsoft Word's contextual spacing behavior: - * 1. Check if contextualSpacing is enabled on the current paragraph - * 2. Check if both paragraphs have style IDs (required for comparison) - * 3. Check if the style IDs match (same style = suppress spacing) - * - * When all conditions are met: - * - spacingBefore is zeroed (prevents adding space before this paragraph) - * - Previous paragraph's spacingAfter (trailingSpacing) is undone by subtracting - * it from cursorY, effectively removing the space already added + * Two independent checks: + * 1. Current paragraph suppresses its own before-spacing (based on current's flag) + * 2. Previous paragraph suppresses its own after-spacing (based on previous's flag, + * carried in state.lastParagraphContextualSpacing) * * Input Validation: * - trailingSpacing is validated to be a finite, non-negative number * - Invalid values (NaN, Infinity, negative, null, undefined) are treated as 0 - * - This prevents layout corruption from malformed input data */ - if (contextualSpacing && state.lastParagraphStyleId && styleId && state.lastParagraphStyleId === styleId) { + // Current paragraph suppresses its own before-spacing + if (shouldSuppressOwnSpacing(styleId, contextualSpacing, state.lastParagraphStyleId)) { spacingBefore = 0; + } + // Previous paragraph suppresses its own after-spacing (rewind trailing) + if (shouldSuppressOwnSpacing(state.lastParagraphStyleId, state.lastParagraphContextualSpacing, styleId)) { const prevTrailing = asSafeNumber(state.trailingSpacing); if (prevTrailing > 0) { state.cursorY -= prevTrailing; @@ -876,5 +878,6 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para lastState.trailingSpacing = 0; } lastState.lastParagraphStyleId = styleId; + lastState.lastParagraphContextualSpacing = contextualSpacing; } } diff --git a/packages/layout-engine/layout-engine/src/layout-utils.d.ts b/packages/layout-engine/layout-engine/src/layout-utils.d.ts index 6bb2fc9a74..377e0d0e32 100644 --- a/packages/layout-engine/layout-engine/src/layout-utils.d.ts +++ b/packages/layout-engine/layout-engine/src/layout-utils.d.ts @@ -19,3 +19,8 @@ export declare const computeFragmentPmRange: ( toLine: number, ) => LinePmRange; export declare const computeLinePmRange: (block: ParagraphBlock, line: Line) => LinePmRange; +export declare function shouldSuppressOwnSpacing( + ownStyleId: string | undefined, + ownContextualSpacing: boolean, + adjacentStyleId: string | undefined, +): boolean; diff --git a/packages/layout-engine/layout-engine/src/layout-utils.test.ts b/packages/layout-engine/layout-engine/src/layout-utils.test.ts index 2e49d880f2..69b562419e 100644 --- a/packages/layout-engine/layout-engine/src/layout-utils.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-utils.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest'; import type { ParagraphBlock, TextRun, ImageRun } from '@superdoc/contracts'; -import { isEmptyTextParagraph, shouldSuppressSpacingForEmpty } from './layout-utils.js'; +import { isEmptyTextParagraph, shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js'; // ============================================================================ // Empty Paragraph Detection Tests @@ -151,3 +151,39 @@ describe('shouldSuppressSpacingForEmpty', () => { expect(shouldSuppressSpacingForEmpty(block, 'after')).toBe(true); }); }); + +// ============================================================================ +// Per-Paragraph Contextual Spacing Tests +// ============================================================================ + +describe('shouldSuppressOwnSpacing', () => { + it('returns true when paragraph has contextualSpacing and adjacent has same styleId', () => { + expect(shouldSuppressOwnSpacing('Normal', true, 'Normal')).toBe(true); + }); + + it('returns false when paragraph does not have contextualSpacing', () => { + expect(shouldSuppressOwnSpacing('Normal', false, 'Normal')).toBe(false); + }); + + it('returns false when styles differ', () => { + expect(shouldSuppressOwnSpacing('Heading1', true, 'Normal')).toBe(false); + }); + + it('returns false when own styleId is undefined', () => { + expect(shouldSuppressOwnSpacing(undefined, true, 'Normal')).toBe(false); + }); + + it('returns false when adjacent styleId is undefined', () => { + expect(shouldSuppressOwnSpacing('Normal', true, undefined)).toBe(false); + }); + + it('returns false when both styleIds are undefined', () => { + expect(shouldSuppressOwnSpacing(undefined, true, undefined)).toBe(false); + }); + + it('does not consult the adjacent paragraph contextualSpacing flag', () => { + // The adjacent paragraph's contextualSpacing is irrelevant — each paragraph + // independently decides whether to suppress its own spacing. + expect(shouldSuppressOwnSpacing('Normal', true, 'Normal')).toBe(true); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/layout-utils.ts b/packages/layout-engine/layout-engine/src/layout-utils.ts index 58173b39da..215b5367c3 100644 --- a/packages/layout-engine/layout-engine/src/layout-utils.ts +++ b/packages/layout-engine/layout-engine/src/layout-utils.ts @@ -157,6 +157,24 @@ export const computeFragmentPmRange = ( export const computeLinePmRange = (block: ParagraphBlock, line: Line): LinePmRange => computeLinePmRangeUnified(block, line); +/** + * Per-paragraph contextual spacing (OOXML w:contextualSpacing). + * + * A paragraph suppresses its own before/after spacing when it has + * contextualSpacing enabled and the adjacent paragraph shares the same styleId. + * The adjacent paragraph's contextualSpacing flag is NOT consulted — each + * paragraph independently decides whether to suppress its own spacing. + * + * @see https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.contextualspacing + */ +export function shouldSuppressOwnSpacing( + ownStyleId: string | undefined, + ownContextualSpacing: boolean, + adjacentStyleId: string | undefined, +): boolean { + return ownContextualSpacing && !!ownStyleId && !!adjacentStyleId && ownStyleId === adjacentStyleId; +} + export const extractBlockPmRange = (block: { attrs?: Record } | null | undefined): LinePmRange => { if (!block || !block.attrs) { return {}; diff --git a/packages/layout-engine/layout-engine/src/paginator.d.ts b/packages/layout-engine/layout-engine/src/paginator.d.ts index 725a31f88c..274d05d3cc 100644 --- a/packages/layout-engine/layout-engine/src/paginator.d.ts +++ b/packages/layout-engine/layout-engine/src/paginator.d.ts @@ -19,6 +19,7 @@ export type PageState = { activeConstraintIndex: number; trailingSpacing: number; lastParagraphStyleId?: string; + lastParagraphContextualSpacing: boolean; }; export type PaginatorOptions = { margins: { diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index ba591945ad..dc73da3188 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -17,6 +17,7 @@ export type PageState = { activeConstraintIndex: number; trailingSpacing: number; lastParagraphStyleId?: string; + lastParagraphContextualSpacing: boolean; }; export type PaginatorOptions = { @@ -96,6 +97,7 @@ export function createPaginator(opts: PaginatorOptions) { activeConstraintIndex: -1, trailingSpacing: 0, lastParagraphStyleId: undefined, + lastParagraphContextualSpacing: false, }; states.push(state); pages.push(state.page); @@ -120,6 +122,7 @@ export function createPaginator(opts: PaginatorOptions) { } state.trailingSpacing = 0; state.lastParagraphStyleId = undefined; + state.lastParagraphContextualSpacing = false; return state; } return startNewPage();