Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92904f169c | ||
|
|
f1b7502e9d | ||
|
|
7162dac6e7 | ||
|
|
cedf4839bd | ||
|
|
a014a907e4 | ||
|
|
8d4f8e666b | ||
|
|
40aa3e05d9 | ||
|
|
92ba3d9590 |
Generated
+1
-1
@@ -12,11 +12,11 @@
|
|||||||
"@carbon/react": "^1.117.0",
|
"@carbon/react": "^1.117.0",
|
||||||
"@carbon/styles": "^1.116.0",
|
"@carbon/styles": "^1.116.0",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@napi-rs/canvas": "*",
|
|
||||||
"@react-pdf/renderer": "^4.9.0",
|
"@react-pdf/renderer": "^4.9.0",
|
||||||
"@tauri-apps/api": "^2.12.1",
|
"@tauri-apps/api": "^2.12.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-is": "^19.3.0"
|
"react-is": "^19.3.0"
|
||||||
|
|||||||
+4
-1
@@ -14,7 +14,9 @@
|
|||||||
"app:dev": "tauri dev",
|
"app:dev": "tauri dev",
|
||||||
"app:build": "tauri build --features custom-protocol",
|
"app:build": "tauri build --features custom-protocol",
|
||||||
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
|
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
|
||||||
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'"
|
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'",
|
||||||
|
"fonts:verify": "node scripts/fonts/verify.mjs",
|
||||||
|
"fonts:manifest": "node scripts/fonts/build-manifest.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@tauri-apps/api": "^2.12.1",
|
"@tauri-apps/api": "^2.12.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-is": "^19.3.0"
|
"react-is": "^19.3.0"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"families": [
|
||||||
|
{
|
||||||
|
"family": "IBM Plex Mono",
|
||||||
|
"folder": "ibm-plex-mono",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "IBMPlexMono-Italic.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "italic",
|
||||||
|
"sha256": "8ebe04c8c6cc82f0be19896ddc61d9935cdd0f027b0173c1945b8d247d7dfc2a",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexMono-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "fe11304a5fe956d5744e9b6a246cc83d90425245e75a62230044966ca96a7f50",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexMono-SemiBold.ttf",
|
||||||
|
"weight": 600,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "c9417148ce13f8fa7d2d5c9180bbc141f72aa0d814ffeb280f6904dc2b1bbd7a",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "IBM Plex Sans",
|
||||||
|
"folder": "ibm-plex-sans",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Light.ttf",
|
||||||
|
"weight": 300,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "2218b5f3f1fc9d3a793343f45c3be5ee7eae9584a7a52391bb8cf2d37622b3e0",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 512,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Italic.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "italic",
|
||||||
|
"sha256": "a9c6ef9942c49e49d11e11a6dacc0b3a087978757e9b22a06b8ac22a6400fb15",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Medium.ttf",
|
||||||
|
"weight": 500,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "331c8639d7598b2cde62a911a71db195e30cb655cd6bdf2e324a7e984955f907",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 520,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-SemiBold.ttf",
|
||||||
|
"weight": 600,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "a20caf8286023a6a7a85e40b1d2a4ae9fc3e3b1f9eda8f4c542dd4986af67bb1",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 522,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Bold.ttf",
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 525,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Noto Sans Devanagari",
|
||||||
|
"folder": "noto-sans-devanagari",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "NotoSansDevanagari-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "306b53ecfb182a504dd8a7446093c316387d2fd8dc350d0792ed1753fe0996cd",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 896,
|
||||||
|
"descent": -408,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 622,
|
||||||
|
"xHeight": 536,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "NotoSansDevanagari-Bold.ttf",
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "3ad8362a06271814869838dcc3d161b13c9fb97681b627af1f7f283ea9387d56",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 896,
|
||||||
|
"descent": -408,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 622,
|
||||||
|
"xHeight": 536,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Noto Sans Kannada",
|
||||||
|
"folder": "noto-sans-kannada",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "NotoSansKannada-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "9ad74dc64838c6855b96f671fc08e425a58921b9d0c71712ea79c328a27e6e38",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 809,
|
||||||
|
"descent": -540,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 690,
|
||||||
|
"xHeight": 551,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "NotoSansKannada-Bold.ttf",
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "3db1507b3f856185fc7c00dc0addfd4797a8ea6955b86d1d21fa659cc9d5e849",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 809,
|
||||||
|
"descent": -540,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 690,
|
||||||
|
"xHeight": 552,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to
|
||||||
|
provide a free and open framework in which fonts may be shared and
|
||||||
|
improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software
|
||||||
|
components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||||
|
modify, redistribute, and sell modified and unmodified copies of the
|
||||||
|
Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created using
|
||||||
|
the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to
|
||||||
|
provide a free and open framework in which fonts may be shared and
|
||||||
|
improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software
|
||||||
|
components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||||
|
modify, redistribute, and sell modified and unmodified copies of the
|
||||||
|
Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created using
|
||||||
|
the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Regenerates public/fonts/manifest.json from the TTFs under public/fonts/<folder>/.
|
||||||
|
// Run: node scripts/fonts/build-manifest.mjs
|
||||||
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FONTS_DIR, MANIFEST_PATH, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
|
||||||
|
|
||||||
|
const families = [];
|
||||||
|
for (const folder of familyFolders()) {
|
||||||
|
const files = ttfFiles(folder);
|
||||||
|
if (files.length === 0) continue;
|
||||||
|
const inspected = files.map((file) => inspectFont(folder, file));
|
||||||
|
const names = new Set(inspected.map((f) => f.familyName));
|
||||||
|
if (names.size !== 1) {
|
||||||
|
throw new Error(`${folder}: mixed family names ${[...names].join(", ")}`);
|
||||||
|
}
|
||||||
|
const licenseFile = "OFL.txt";
|
||||||
|
if (!existsSync(path.join(FONTS_DIR, folder, licenseFile))) {
|
||||||
|
throw new Error(`${folder}: missing ${licenseFile}`);
|
||||||
|
}
|
||||||
|
const fonts = inspected
|
||||||
|
.map((f) => f.info)
|
||||||
|
.sort((a, b) => a.weight - b.weight || a.style.localeCompare(b.style) || a.file.localeCompare(b.file));
|
||||||
|
families.push({ family: [...names][0], folder, license: "OFL-1.1", licenseFile, fonts });
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(MANIFEST_PATH, `${JSON.stringify({ version: 1, families }, null, 2)}\n`);
|
||||||
|
console.log(
|
||||||
|
`manifest.json: ${families.length} families, ${families.reduce((n, f) => n + f.fonts.length, 0)} fonts`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Shared helpers for the font manifest builder and verifier (Node only).
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import * as fontkit from "fontkit";
|
||||||
|
|
||||||
|
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
export const FONTS_DIR = path.join(ROOT, "public/fonts");
|
||||||
|
export const MANIFEST_PATH = path.join(FONTS_DIR, "manifest.json");
|
||||||
|
|
||||||
|
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||||
|
|
||||||
|
/** Folders under public/fonts that hold bundled families (everything that is a directory). */
|
||||||
|
export function familyFolders() {
|
||||||
|
return readdirSync(FONTS_DIR)
|
||||||
|
.filter((name) => statSync(path.join(FONTS_DIR, name)).isDirectory())
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ttfFiles(folder) {
|
||||||
|
return readdirSync(path.join(FONTS_DIR, folder))
|
||||||
|
.filter((name) => /\.ttf$/i.test(name))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First four bytes as a tag: "\0\1\0\0" is reported as "0x00010000". */
|
||||||
|
export function sfntTag(bytes) {
|
||||||
|
const b = bytes.subarray(0, 4);
|
||||||
|
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "0x00010000";
|
||||||
|
return String.fromCharCode(...b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw OS/2 fsType (fontkit only exposes decoded flags). */
|
||||||
|
function rawFsType(bytes) {
|
||||||
|
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const numTables = dv.getUint16(4);
|
||||||
|
for (let i = 0; i < numTables; i++) {
|
||||||
|
const rec = 12 + i * 16;
|
||||||
|
const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
|
||||||
|
if (tag === "OS/2") return dv.getUint16(dv.getUint32(rec + 8) + 8);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a TTF with fontkit and returns the facts the manifest records. */
|
||||||
|
export function inspectFont(folder, file) {
|
||||||
|
const bytes = new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
|
||||||
|
const font = fontkit.create(Buffer.from(bytes));
|
||||||
|
if (font.fonts) throw new Error(`${folder}/${file}: font collections are not supported`);
|
||||||
|
const os2 = font["OS/2"];
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
tag: sfntTag(bytes),
|
||||||
|
variable: Boolean(font.directory?.tables?.fvar),
|
||||||
|
// Name ID 16 (typographic family) groups SemiBold etc. under the base family.
|
||||||
|
familyName: font.name?.records?.preferredFamily?.en ?? font.familyName,
|
||||||
|
info: {
|
||||||
|
file,
|
||||||
|
weight: os2.usWeightClass,
|
||||||
|
style: os2.fsSelection.italic || os2.fsSelection.oblique ? "italic" : "normal",
|
||||||
|
sha256: sha256(bytes),
|
||||||
|
fsType: rawFsType(bytes),
|
||||||
|
upm: font.unitsPerEm,
|
||||||
|
ascent: font.ascent,
|
||||||
|
descent: font.descent,
|
||||||
|
lineGap: font.lineGap,
|
||||||
|
capHeight: font.capHeight,
|
||||||
|
xHeight: font.xHeight,
|
||||||
|
hasRupee: font.hasGlyphForCodePoint(0x20b9),
|
||||||
|
hasTabularNums: font.availableFeatures.includes("tnum"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEIGHT_WORDS = {
|
||||||
|
Thin: 100, ExtraLight: 200, Light: 300, Regular: 400, Italic: 400, Text: 450,
|
||||||
|
Medium: 500, SemiBold: 600, Bold: 700, ExtraBold: 800, Black: 900,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Checks public/fonts against manifest.json. Exits non-zero with readable messages on any problem.
|
||||||
|
// Run: npm run fonts:verify
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FONTS_DIR, MANIFEST_PATH, WEIGHT_WORDS, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
const fail = (where, msg) => problems.push(`${where}: ${msg}`);
|
||||||
|
|
||||||
|
if (!existsSync(MANIFEST_PATH)) {
|
||||||
|
console.error("public/fonts/manifest.json is missing; run node scripts/fonts/build-manifest.mjs");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
||||||
|
if (manifest.version !== 1) fail("manifest", `unsupported version ${manifest.version}`);
|
||||||
|
|
||||||
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
const listed = new Set();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const fam of manifest.families) {
|
||||||
|
const dir = `public/fonts/${fam.folder}`;
|
||||||
|
if (slug(fam.family) !== fam.folder) fail(dir, `folder does not match family "${fam.family}"`);
|
||||||
|
if (!existsSync(path.join(FONTS_DIR, fam.folder, fam.licenseFile))) {
|
||||||
|
fail(dir, `licence file ${fam.licenseFile} is missing`);
|
||||||
|
}
|
||||||
|
for (const entry of fam.fonts) {
|
||||||
|
const where = `${fam.folder}/${entry.file}`;
|
||||||
|
listed.add(where);
|
||||||
|
count++;
|
||||||
|
if (!existsSync(path.join(FONTS_DIR, fam.folder, entry.file))) {
|
||||||
|
fail(where, "listed in the manifest but not on disk");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let f;
|
||||||
|
try {
|
||||||
|
f = inspectFont(fam.folder, entry.file);
|
||||||
|
} catch (err) {
|
||||||
|
fail(where, `cannot be parsed: ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (f.tag !== "0x00010000" && f.tag !== "true") {
|
||||||
|
fail(where, `not a TrueType font (magic "${f.tag}"); WOFF, WOFF2 and OpenType/CFF must not be bundled for PDFs`);
|
||||||
|
}
|
||||||
|
if (f.variable) fail(where, "variable font (has fvar); react-pdf would embed only the default instance");
|
||||||
|
if (f.info.sha256 !== entry.sha256) fail(where, "sha256 differs from the manifest; rebuild it if the change is intended");
|
||||||
|
if (f.info.fsType !== 0 && f.info.fsType !== 8) {
|
||||||
|
fail(where, `fsType ${f.info.fsType} does not allow embedding (need 0 or 8)`);
|
||||||
|
}
|
||||||
|
if (f.familyName !== fam.family) fail(where, `family is "${f.familyName}", manifest says "${fam.family}"`);
|
||||||
|
if (f.info.weight !== entry.weight || f.info.style !== entry.style) {
|
||||||
|
fail(where, `weight/style ${f.info.weight}/${f.info.style} differs from the manifest ${entry.weight}/${entry.style}`);
|
||||||
|
}
|
||||||
|
const word = entry.file.replace(/\.ttf$/i, "").split("-").pop();
|
||||||
|
const expected = WEIGHT_WORDS[word];
|
||||||
|
if (expected !== undefined && word !== "Text" && expected !== f.info.weight) {
|
||||||
|
fail(where, `file name says ${word} (${expected}) but OS/2 weight is ${f.info.weight}`);
|
||||||
|
}
|
||||||
|
if (/Italic$/.test(word) !== (f.info.style === "italic")) {
|
||||||
|
fail(where, `file name and OS/2 disagree about italic (${f.info.style})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const folder of familyFolders()) {
|
||||||
|
for (const file of ttfFiles(folder)) {
|
||||||
|
if (!listed.has(`${folder}/${file}`)) fail(`${folder}/${file}`, "TTF on disk but not in the manifest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length) {
|
||||||
|
console.error(`fonts:verify found ${problems.length} problem(s):`);
|
||||||
|
for (const p of problems) console.error(` - ${p}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`fonts:verify OK: ${manifest.families.length} families, ${count} fonts`);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Child-process scenarios for src/pdf/hang.test.ts. Bundled with esbuild and run with node, because
|
||||||
|
// react-pdf's paginate() is a synchronous loop: an in-process timer never fires while it spins.
|
||||||
|
import { createElement as h } from "react";
|
||||||
|
import { Document, Page, Path, Svg, Text, View } from "@react-pdf/renderer";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FirstPageHeader } from "../../src/pdf/blocks/FirstPageHeader";
|
||||||
|
import { find } from "../../src/pdf/testing/docs";
|
||||||
|
import { registerBundledFonts, stack } from "../../src/pdf/fonts/register";
|
||||||
|
import { renderCore } from "../../src/pdf/render/core";
|
||||||
|
|
||||||
|
registerBundledFonts(path.resolve(process.cwd(), "public/fonts"));
|
||||||
|
|
||||||
|
const [scenario, rowsArg] = process.argv.slice(2);
|
||||||
|
const rows = Number(rowsArg);
|
||||||
|
const W = 595.28;
|
||||||
|
const H = 841.89;
|
||||||
|
const PAD = 40;
|
||||||
|
const text = { fontFamily: stack("IBM Plex Sans"), fontSize: 10, lineHeight: "12pt" };
|
||||||
|
|
||||||
|
const flow = Array.from({ length: rows }, (_, i) =>
|
||||||
|
h(View, { key: i, style: { height: 18 } }, h(Text, { style: text }, `Row ${i + 1}`)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const svg = (extra: Record<string, unknown>) =>
|
||||||
|
h(
|
||||||
|
Svg,
|
||||||
|
{ viewBox: "0 0 100 40", style: { width: W, height: 40, ...extra } },
|
||||||
|
h(Path, { d: "M0 0 L100 0 L100 40 L0 40 Z", fill: "#cccccc" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function pageWith(children: unknown[]) {
|
||||||
|
return h(Document, null, h(Page, { size: { width: W, height: H }, style: { padding: PAD } }, ...(children as never[])));
|
||||||
|
}
|
||||||
|
|
||||||
|
const element =
|
||||||
|
scenario === "page-level-svg"
|
||||||
|
? // Non-fixed, absolute, full width, straddling the bottom margin line (H - PAD = 801.89).
|
||||||
|
pageWith([...flow, svg({ position: "absolute", left: 0, top: H - PAD - 20 })])
|
||||||
|
: pageWith([
|
||||||
|
h(
|
||||||
|
FirstPageHeader,
|
||||||
|
{
|
||||||
|
key: "head",
|
||||||
|
pad: { top: PAD, right: PAD, bottom: PAD, left: PAD },
|
||||||
|
decor: [{ x: 0, y: 10, width: W, height: 40, children: svg({}) }],
|
||||||
|
style: { height: 60 },
|
||||||
|
},
|
||||||
|
h(Text, { style: text }, "Header"),
|
||||||
|
),
|
||||||
|
...flow,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const r = await renderCore(element);
|
||||||
|
const svgPages = r.layout.map((p) => find(p, (n) => n.type === "svg").length);
|
||||||
|
console.log(JSON.stringify({ pages: r.pages, svgPages }));
|
||||||
Generated
+2
@@ -4236,10 +4236,12 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"percent-encoding",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
percent-encoding = "2"
|
||||||
|
|
||||||
# Desktop-only plugins (not available on mobile targets).
|
# Desktop-only plugins (not available on mobile targets).
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//! Content-addressed archive of the searchable PDF produced when an invoice is issued.
|
||||||
|
//! Files live at `<local data dir>/archive/<sha256>.pdf`; the invoice row keeps the hash.
|
||||||
|
use super::raw::{header_map, raw_body, required_header, write_atomic, Headers};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::ipc::{Request, Response};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
const MAX_FINGERPRINT_LEN: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ArchiveStatus {
|
||||||
|
pub archived: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archived_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash comes from the database, but it ends up in a file path, so check its shape.
|
||||||
|
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
|
||||||
|
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||||
|
if !valid {
|
||||||
|
return Err("The stored archive hash is malformed".to_string());
|
||||||
|
}
|
||||||
|
Ok(local_dir.join("archive").join(format!("{sha256}.pdf")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_invoice_id(headers: &Headers) -> Result<i64, String> {
|
||||||
|
required_header(headers, "x-invoice-id")?
|
||||||
|
.trim()
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|_| "x-invoice-id must be a number".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn archive_pdf_impl(
|
||||||
|
conn: &mut Connection,
|
||||||
|
local_dir: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
headers: &Headers,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let id = parse_invoice_id(headers)?;
|
||||||
|
let fingerprint = required_header(headers, "x-fingerprint")?.trim();
|
||||||
|
if fingerprint.is_empty() || fingerprint.len() > MAX_FINGERPRINT_LEN {
|
||||||
|
return Err("x-fingerprint must be 1-256 characters".to_string());
|
||||||
|
}
|
||||||
|
if !bytes.starts_with(b"%PDF-") {
|
||||||
|
return Err("The data is not a PDF".to_string());
|
||||||
|
}
|
||||||
|
if bytes.len() > MAX_ARCHIVE_BYTES {
|
||||||
|
return Err("The PDF is larger than the 64 MB archive limit".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let row: Option<(String, Option<String>)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT status, archived_pdf_sha256 FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
|
if status != "issued" && status != "cancelled" {
|
||||||
|
return Err(format!("Only an issued invoice can be archived (this one is {status})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sha = sha256_hex(bytes);
|
||||||
|
if let Some(existing) = &existing {
|
||||||
|
if *existing != sha {
|
||||||
|
return Err(
|
||||||
|
"This invoice already has an archived original; the issued PDF cannot be replaced"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = archive_path(local_dir, &sha)?;
|
||||||
|
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
||||||
|
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
||||||
|
// Same content, same name; a matching length means the file is already in place.
|
||||||
|
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
|
||||||
|
if !present {
|
||||||
|
write_atomic(&path, bytes)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.is_none() {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE invoices SET archived_pdf_sha256 = ?1, archived_fingerprint = ?2, archived_at = ?3
|
||||||
|
WHERE id = ?4",
|
||||||
|
params![sha, fingerprint, now, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
|
||||||
|
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
||||||
|
let path = archive_path(local_dir, &sha)?;
|
||||||
|
let bytes = std::fs::read(&path).map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
"The archived PDF file is missing".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Could not read the archived PDF: {e}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if sha256_hex(&bytes) != sha {
|
||||||
|
return Err("The archived PDF is corrupted (its checksum no longer matches)".to_string());
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
||||||
|
let row: Option<Option<String>> = conn
|
||||||
|
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
row.ok_or_else(|| "Invoice not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus, String> {
|
||||||
|
let row: Option<(Option<String>, Option<String>, Option<String>)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
|
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let bytes = raw_body(&request)?;
|
||||||
|
let headers = header_map(&request);
|
||||||
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
archive_pdf_impl(&mut conn, &state.local_data_dir, bytes, &headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result<Response, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
read_archive_impl(&conn, &state.local_data_dir, invoice_id).map(Response::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
archive_status_impl(&conn, invoice_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn conn_with_invoice(status: &str) -> Connection {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, client_name, status, created_at, updated_at)
|
||||||
|
VALUES ('INV/2026-001', '2026-04-01', 'Client', ?1, 'now', 'now')",
|
||||||
|
params![status],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers(id: &str) -> Headers {
|
||||||
|
Headers::from([
|
||||||
|
("x-invoice-id".to_string(), id.to_string()),
|
||||||
|
("x-fingerprint".to_string(), "fp-1".to_string()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDF: &[u8] = b"%PDF-1.7\nbody";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archives_once_and_is_idempotent() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(sha.len(), 64);
|
||||||
|
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF);
|
||||||
|
|
||||||
|
let status = archive_status_impl(&conn, 1).unwrap();
|
||||||
|
assert!(status.archived);
|
||||||
|
assert_eq!(status.sha256.as_deref(), Some(sha.as_str()));
|
||||||
|
assert_eq!(status.fingerprint.as_deref(), Some("fp-1"));
|
||||||
|
let first_at = status.archived_at.clone().unwrap();
|
||||||
|
|
||||||
|
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(again, sha);
|
||||||
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at);
|
||||||
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_invoices_can_be_archived() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("cancelled");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_bytes_for_an_archived_invoice_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
||||||
|
assert!(err.contains("already has an archived original"), "{err}");
|
||||||
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
||||||
|
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_pdf_and_oversize_bodies_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("not a PDF"));
|
||||||
|
let mut big = b"%PDF-".to_vec();
|
||||||
|
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("64 MB"));
|
||||||
|
assert!(!archive_status_impl(&conn, 1).unwrap().archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drafts_unknown_invoices_and_bad_headers_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("draft");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Only an issued invoice"));
|
||||||
|
assert_eq!(
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("99")).unwrap_err(),
|
||||||
|
"Invoice not found"
|
||||||
|
);
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("abc")).is_err());
|
||||||
|
let mut no_fp = headers("1");
|
||||||
|
no_fp.remove("x-fingerprint");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &no_fp).is_err());
|
||||||
|
assert!(!dir.path().join("archive").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_archive_detects_corruption_and_missing_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF"));
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
||||||
|
|
||||||
|
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted"));
|
||||||
|
|
||||||
|
std::fs::remove_file(&file).unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing"));
|
||||||
|
// Archiving the same bytes again restores the file.
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_stored_hash_never_becomes_a_path() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = conn_with_invoice("issued");
|
||||||
|
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
|
||||||
|
.unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,22 +133,6 @@ pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String>
|
|||||||
remove_asset_file(&state.data_dir, &path)
|
remove_asset_file(&state.data_dir, &path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
|
|
||||||
if path.trim().is_empty() {
|
|
||||||
return Err("No file path given".into());
|
|
||||||
}
|
|
||||||
let bytes = STANDARD
|
|
||||||
.decode(data_base64.as_bytes())
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
if let Some(parent) = Path::new(&path).parent() {
|
|
||||||
// A missing parent is created; if that fails the write below reports the real error.
|
|
||||||
std::fs::create_dir_all(parent).ok();
|
|
||||||
}
|
|
||||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -248,9 +232,4 @@ mod tests {
|
|||||||
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
||||||
assert!(dir.path().join("voiced.db").exists());
|
assert!(dir.path().join("voiced.db").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_binary_file_rejects_empty_path() {
|
|
||||||
assert!(save_binary_file(" ".into(), "AAAA".into()).is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! User-visible files: exports written to a path from the save dialog, and the
|
||||||
|
//! reveal/open helpers used afterwards.
|
||||||
|
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use tauri::ipc::Request;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
pub fn write_export_file_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
bytes: &[u8],
|
||||||
|
headers: &Headers,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let raw = decode_header_path(required_header(headers, "x-path")?)?;
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Err("No file path given".to_string());
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(&raw);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("The export path must be absolute".to_string());
|
||||||
|
}
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err("Nothing to write: the file is empty".to_string());
|
||||||
|
}
|
||||||
|
let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?;
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
|
write_atomic(&path, bytes)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET last_export_dir = ?1 WHERE id = 1",
|
||||||
|
params![parent.to_string_lossy()],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
|
||||||
|
conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_existing(path: &str) -> Result<PathBuf, String> {
|
||||||
|
if path.trim().is_empty() {
|
||||||
|
return Err("No file path given".to_string());
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!("{} does not exist", path.display()));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last resort when the opener plugin cannot reach a file manager (a minimal Linux
|
||||||
|
/// install has no D-Bus file-manager service or portal). Arguments are passed
|
||||||
|
/// individually; nothing goes through a shell.
|
||||||
|
fn reveal_fallback_command(path: &Path) -> Command {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let mut cmd = Command::new("explorer");
|
||||||
|
cmd.arg(format!("/select,{}", path.display()));
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let mut cmd = Command::new("open");
|
||||||
|
cmd.arg("-R").arg(path);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
||||||
|
let mut cmd = Command::new("xdg-open");
|
||||||
|
cmd.arg(folder);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_fallback_command(path: &Path) -> Command {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let mut cmd = Command::new("explorer");
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let mut cmd = Command::new("open");
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let mut cmd = Command::new("xdg-open");
|
||||||
|
cmd.arg(path);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
||||||
|
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?;
|
||||||
|
// Reap the child so it does not linger as a zombie; the result is irrelevant
|
||||||
|
// (explorer.exe, for one, exits non-zero even on success).
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let bytes = raw_body(&request)?;
|
||||||
|
let headers = header_map(&request);
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
write_export_file_impl(&conn, bytes, &headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_last_export_dir(state: State<AppState>) -> Result<String, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
get_last_export_dir_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
|
let path = require_existing(&path)?;
|
||||||
|
if app.opener().reveal_item_in_dir(&path).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
spawn_detached(reveal_fallback_command(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
|
let path = require_existing(&path)?;
|
||||||
|
if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
spawn_detached(open_fallback_command(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn headers(path: &str) -> Headers {
|
||||||
|
Headers::from([("x-path".to_string(), path.to_string())])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(path: &Path) -> String {
|
||||||
|
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_the_file_and_remembers_the_folder() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
|
||||||
|
// Non-ASCII, spaces and a folder that does not exist yet.
|
||||||
|
let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
|
||||||
|
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
||||||
|
assert_eq!(PathBuf::from(&written), target);
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
||||||
|
assert_eq!(
|
||||||
|
get_last_export_dir_impl(&conn).unwrap(),
|
||||||
|
target.parent().unwrap().to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Overwrites in place.
|
||||||
|
write_export_file_impl(&conn, b"%PDF-2", &headers(&encode(&target))).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_relative_and_missing_paths() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
|
||||||
|
assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_empty_body_without_touching_the_folder_setting() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let target = dir.path().join("a.pdf");
|
||||||
|
assert!(write_export_file_impl(&conn, b"", &headers(&encode(&target))).is_err());
|
||||||
|
assert!(!target.exists());
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reveal_and_open_refuse_missing_paths() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(require_existing("").is_err());
|
||||||
|
let missing = dir.path().join("nope.pdf");
|
||||||
|
assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist"));
|
||||||
|
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn linux_fallback_opens_the_parent_folder_with_separate_args() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("a b; rm -rf.pdf");
|
||||||
|
std::fs::write(&file, b"x").unwrap();
|
||||||
|
let cmd = reveal_fallback_command(&file);
|
||||||
|
assert_eq!(cmd.get_program(), "xdg-open");
|
||||||
|
let args: Vec<_> = cmd.get_args().collect();
|
||||||
|
assert_eq!(args, [dir.path().as_os_str()]);
|
||||||
|
let open = open_fallback_command(&file);
|
||||||
|
assert_eq!(open.get_args().collect::<Vec<_>>(), [file.as_os_str()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, clien
|
|||||||
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
||||||
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
||||||
cancel_reason, archived_pdf_sha256";
|
cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, ''), archived_fingerprint, archived_at";
|
||||||
|
|
||||||
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||||
Ok(Invoice {
|
Ok(Invoice {
|
||||||
@@ -55,6 +55,9 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|||||||
cancelled_at: row.get(31)?,
|
cancelled_at: row.get(31)?,
|
||||||
cancel_reason: row.get(32)?,
|
cancel_reason: row.get(32)?,
|
||||||
archived_pdf_sha256: row.get(33)?,
|
archived_pdf_sha256: row.get(33)?,
|
||||||
|
render_prefs: row.get(34)?,
|
||||||
|
archived_fingerprint: row.get(35)?,
|
||||||
|
archived_at: row.get(36)?,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -922,6 +925,7 @@ mod tests {
|
|||||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
||||||
|
assert_eq!(inv.render_prefs, prefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
pub mod archive;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod files;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
|
pub mod raw;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Helpers for commands that take their payload as a raw IPC body (no base64).
|
||||||
|
//!
|
||||||
|
//! The webview sends `invoke(cmd, Uint8Array, { headers })`; Tauri hands the bytes over as
|
||||||
|
//! `InvokeBody::Raw` and the headers as an `http::HeaderMap`. Header values must be visible
|
||||||
|
//! ASCII (`HeaderValue::from_str` rejects anything else), so non-ASCII data such as file
|
||||||
|
//! paths is percent-encoded by the caller.
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::ipc::{InvokeBody, Request};
|
||||||
|
|
||||||
|
/// Lower-cased header name to value; values that are not valid UTF-8 are dropped.
|
||||||
|
pub type Headers = HashMap<String, String>;
|
||||||
|
|
||||||
|
pub fn raw_body<'a>(request: &'a Request<'_>) -> Result<&'a [u8], String> {
|
||||||
|
match request.body() {
|
||||||
|
InvokeBody::Raw(bytes) => Ok(bytes),
|
||||||
|
InvokeBody::Json(_) => Err("Expected a raw binary request body".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn header_map(request: &Request<'_>) -> Headers {
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(name, value)| {
|
||||||
|
let value = value.to_str().ok()?;
|
||||||
|
Some((name.as_str().to_ascii_lowercase(), value.to_string()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn required_header<'a>(headers: &'a Headers, name: &str) -> Result<&'a str, String> {
|
||||||
|
headers
|
||||||
|
.get(name)
|
||||||
|
.map(String::as_str)
|
||||||
|
.ok_or_else(|| format!("Missing {name} header"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_header_path(value: &str) -> Result<String, String> {
|
||||||
|
percent_decode_str(value)
|
||||||
|
.decode_utf8()
|
||||||
|
.map(|s| s.into_owned())
|
||||||
|
.map_err(|_| "The path header is not valid UTF-8".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bytes` to `path` via a sibling temp file and a rename, so a crash never leaves a
|
||||||
|
/// half-written file under the final name.
|
||||||
|
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut tmp_name = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| "The path has no file name".to_string())?
|
||||||
|
.to_os_string();
|
||||||
|
tmp_name.push(".tmp");
|
||||||
|
let tmp = path.with_file_name(tmp_name);
|
||||||
|
let result = (|| {
|
||||||
|
let mut file = std::fs::File::create(&tmp)?;
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
})();
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(format!("Could not write {}: {e}", path.display()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_percent_encoded_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
decode_header_path("%2Ftmp%2Fa%20b%2F%E0%A6%AC.pdf").unwrap(),
|
||||||
|
"/tmp/a b/\u{9ac}.pdf"
|
||||||
|
);
|
||||||
|
assert!(decode_header_path("%FF").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atomic_write_replaces_and_leaves_no_temp_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let target = dir.path().join("out.pdf");
|
||||||
|
write_atomic(&target, b"one").unwrap();
|
||||||
|
write_atomic(&target, b"two").unwrap();
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"two");
|
||||||
|
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use tauri::State;
|
|||||||
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||||
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
||||||
signatory_designation";
|
signatory_designation, render_prefs, last_export_dir";
|
||||||
|
|
||||||
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
@@ -30,6 +30,8 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
|||||||
default_hsn_sac: row.get(17)?,
|
default_hsn_sac: row.get(17)?,
|
||||||
signatory_name: row.get(18)?,
|
signatory_name: row.get(18)?,
|
||||||
signatory_designation: row.get(19)?,
|
signatory_designation: row.get(19)?,
|
||||||
|
render_prefs: row.get(20)?,
|
||||||
|
last_export_dir: row.get(21)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
||||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||||
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
||||||
signatory_name = ?19, signatory_designation = ?20
|
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21
|
||||||
WHERE id = 1"#,
|
WHERE id = 1"#,
|
||||||
params![
|
params![
|
||||||
settings.vendor_name,
|
settings.vendor_name,
|
||||||
@@ -77,6 +79,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
settings.default_hsn_sac,
|
settings.default_hsn_sac,
|
||||||
settings.signatory_name,
|
settings.signatory_name,
|
||||||
settings.signatory_designation,
|
settings.signatory_designation,
|
||||||
|
settings.render_prefs,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -230,6 +233,26 @@ mod tests {
|
|||||||
(setting, flagged)
|
(setting, flagged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_carry_render_prefs() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let read = |conn: &Connection| {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||||
|
[],
|
||||||
|
map_settings,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(read(&conn).render_prefs, "");
|
||||||
|
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
|
||||||
|
.unwrap();
|
||||||
|
let settings = read(&conn);
|
||||||
|
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
||||||
|
let json = serde_json::to_value(&settings).unwrap();
|
||||||
|
assert_eq!(json["renderPrefs"], "{\"version\":1}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
|||||||
+51
-2
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
|||||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
const LATEST_VERSION: i64 = 2;
|
const LATEST_VERSION: i64 = 4;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -169,8 +169,20 @@ CREATE TABLE IF NOT EXISTS invoice_drafts (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Version 3: the user's default page setup (RenderPrefsV1 JSON; empty means built-in defaults).
|
||||||
|
const M3: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT '';
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 4: the archived PDF's layout fingerprint and time, and the last export folder.
|
||||||
|
const M4: &str = r#"
|
||||||
|
ALTER TABLE invoices ADD COLUMN archived_fingerprint TEXT;
|
||||||
|
ALTER TABLE invoices ADD COLUMN archived_at TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN last_export_dir TEXT NOT NULL DEFAULT '';
|
||||||
|
"#;
|
||||||
|
|
||||||
fn migrations() -> Migrations<'static> {
|
fn migrations() -> Migrations<'static> {
|
||||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2)])
|
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||||
@@ -536,6 +548,43 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert!(backups_in(&backups).is_empty());
|
assert!(backups_in(&backups).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m4_adds_archive_and_export_dir_columns() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let dir: String = conn
|
||||||
|
.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dir, "");
|
||||||
|
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
||||||
|
conn.prepare("SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices")
|
||||||
|
.unwrap();
|
||||||
|
let notnull: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT SUM(\"notnull\") FROM pragma_table_info('invoices')
|
||||||
|
WHERE name IN ('archived_fingerprint', 'archived_at')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(notnull, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let initial: String = conn
|
||||||
|
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(initial, "");
|
||||||
|
|
||||||
|
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
|
||||||
|
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap();
|
||||||
|
let stored: String = conn
|
||||||
|
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored, json);
|
||||||
|
}
|
||||||
|
|
||||||
fn build_v1_database(path: &Path) {
|
fn build_v1_database(path: &Path) {
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = Connection::open(path).unwrap();
|
||||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||||
|
|||||||
+25
-1
@@ -2,6 +2,7 @@ mod commands;
|
|||||||
mod db;
|
mod db;
|
||||||
mod gst;
|
mod gst;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod selftest;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -12,6 +13,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Mutex<Connection>,
|
pub db: Mutex<Connection>,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
|
/// Machine-local, non-roaming data (the PDF archive). On Windows this is
|
||||||
|
/// %LOCALAPPDATA%, unlike `data_dir`, which is the roaming location.
|
||||||
|
pub local_data_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a native error dialog. Release builds have no console on Windows, so
|
/// Show a native error dialog. Release builds have no console on Windows, so
|
||||||
@@ -40,6 +44,17 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
let local_data_dir = match app.path().app_local_data_dir() {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e) => {
|
||||||
|
show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
||||||
|
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -54,6 +69,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
data_dir,
|
data_dir,
|
||||||
|
local_data_dir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +129,15 @@ pub fn run() {
|
|||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
commands::assets::remove_asset,
|
commands::assets::remove_asset,
|
||||||
commands::assets::save_binary_file,
|
commands::archive::archive_pdf,
|
||||||
|
commands::archive::read_archive,
|
||||||
|
commands::archive::archive_status,
|
||||||
|
commands::files::write_export_file,
|
||||||
|
commands::files::get_last_export_dir,
|
||||||
|
commands::files::reveal_in_folder,
|
||||||
|
commands::files::open_file,
|
||||||
|
selftest::selftest_config,
|
||||||
|
selftest::selftest_report,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!());
|
.run(tauri::generate_context!());
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ pub struct Settings {
|
|||||||
pub default_hsn_sac: String,
|
pub default_hsn_sac: String,
|
||||||
pub signatory_name: String,
|
pub signatory_name: String,
|
||||||
pub signatory_designation: String,
|
pub signatory_designation: String,
|
||||||
|
/// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults.
|
||||||
|
#[serde(default)]
|
||||||
|
pub render_prefs: String,
|
||||||
|
/// Folder of the last export; owned by the backend, so saving settings never changes it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_export_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -194,6 +200,10 @@ pub struct Invoice {
|
|||||||
pub cancelled_at: Option<String>,
|
pub cancelled_at: Option<String>,
|
||||||
pub cancel_reason: String,
|
pub cancel_reason: String,
|
||||||
pub archived_pdf_sha256: Option<String>,
|
pub archived_pdf_sha256: Option<String>,
|
||||||
|
/// RenderPrefsV1 JSON frozen at issue; empty when none was stored.
|
||||||
|
pub render_prefs: String,
|
||||||
|
pub archived_fingerprint: Option<String>,
|
||||||
|
pub archived_at: Option<String>,
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//! Unattended end-to-end self-test hook. Inert unless `VOICED_SELFTEST_OUT` is set: the webview asks
|
||||||
|
//! for the report path at startup, runs its checks and hands the JSON back, and the process exits with
|
||||||
|
//! the verdict. This is the release gate that proves the real webview works, which Node tests cannot.
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
|
||||||
|
|
||||||
|
fn configured_path() -> Option<PathBuf> {
|
||||||
|
std::env::var_os(ENV_VAR).filter(|v| !v.is_empty()).map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_report(path: &Path, json: &str) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let mut file = std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?;
|
||||||
|
file.write_all(json.as_bytes())
|
||||||
|
.and_then(|()| file.sync_all())
|
||||||
|
.map_err(|e| format!("Could not write the report: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn selftest_config() -> Option<String> {
|
||||||
|
configured_path().map(|p| p.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn selftest_report(json: String, ok: bool) -> Result<(), String> {
|
||||||
|
let path = configured_path().ok_or_else(|| "The self-test is not enabled".to_string())?;
|
||||||
|
write_report(&path, &json)?;
|
||||||
|
std::process::exit(if ok { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_the_report_creating_folders() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("a/b/report.json");
|
||||||
|
write_report(&path, "{\"ok\":true}").unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, ComposedModal, InlineLoading, InlineNotification, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react";
|
||||||
|
import { reportToText, runSelfTest, type Check, type SelfTestReport } from "../lib/selfTest";
|
||||||
|
|
||||||
|
const STATUS_TAG: Record<Check["status"], { type: "green" | "red" | "gray"; text: string }> = {
|
||||||
|
pass: { type: "green", text: "Pass" },
|
||||||
|
fail: { type: "red", text: "Fail" },
|
||||||
|
info: { type: "gray", text: "Info" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders the fixtures in this webview's real render client and reports what it found. */
|
||||||
|
export default function DiagnosticsPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const [report, setReport] = useState<SelfTestReport | null>(null);
|
||||||
|
const [fatal, setFatal] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<"ok" | "failed" | null>(null);
|
||||||
|
const runId = useRef(0);
|
||||||
|
|
||||||
|
const run = useCallback(async () => {
|
||||||
|
const id = ++runId.current;
|
||||||
|
setReport(null);
|
||||||
|
setFatal(null);
|
||||||
|
setCopied(null);
|
||||||
|
try {
|
||||||
|
// Loaded on demand: the fixtures, goldens and pdf.js are only needed here.
|
||||||
|
const { browserSelfTestDeps } = await import("../lib/selfTestBrowser");
|
||||||
|
await runSelfTest(browserSelfTestDeps(), (r) => {
|
||||||
|
if (runId.current === id) setReport(r);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (runId.current === id) setFatal(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void run();
|
||||||
|
else runId.current++;
|
||||||
|
}, [open, run]);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
if (!report) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reportToText(report));
|
||||||
|
setCopied("ok");
|
||||||
|
} catch {
|
||||||
|
setCopied("failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const running = open && !fatal && !report?.done;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposedModal open={open} onClose={onClose} size="md" preventCloseOnClickOutside aria-label="Diagnostics">
|
||||||
|
<ModalHeader title="Diagnostics" label="Settings" />
|
||||||
|
<ModalBody hasScrollingContent>
|
||||||
|
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
|
||||||
|
Renders two sample invoices in this app's own renderer, reads them back and compares them with the reference layouts. Nothing is saved or sent.
|
||||||
|
</p>
|
||||||
|
{fatal ? <InlineNotification kind="error" lowContrast hideCloseButton title="The self-test could not run" subtitle={fatal} /> : null}
|
||||||
|
{report && report.done ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind={report.passed ? "success" : "error"}
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title={report.passed ? "All checks passed" : "Some checks failed"}
|
||||||
|
subtitle={report.passed ? undefined : "Copy the report and include it when you report the problem."}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{report?.sections.map((section) => (
|
||||||
|
<section key={section.title} style={{ marginTop: "1.5rem" }} aria-label={section.title}>
|
||||||
|
<h4 style={{ marginBottom: "0.5rem" }}>{section.title}</h4>
|
||||||
|
{section.checks.map((check, i) => (
|
||||||
|
<div key={`${check.label}-${i}`} className="voiced-diagnostics__check">
|
||||||
|
<div className="voiced-diagnostics__head">
|
||||||
|
<Tag type={STATUS_TAG[check.status].type} size="sm">
|
||||||
|
{STATUS_TAG[check.status].text}
|
||||||
|
</Tag>
|
||||||
|
<span className="voiced-diagnostics__label">{check.label}</span>
|
||||||
|
{check.ms !== undefined ? <span className="voiced-diagnostics__ms">{check.ms} ms</span> : null}
|
||||||
|
</div>
|
||||||
|
{check.detail ? <p className="voiced-diagnostics__detail">{check.detail}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{running ? (
|
||||||
|
<div style={{ marginTop: "1.5rem" }}>
|
||||||
|
<InlineLoading description="Running checks…" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{copied ? (
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<InlineNotification
|
||||||
|
kind={copied === "ok" ? "success" : "warning"}
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title={copied === "ok" ? "Report copied" : "Could not copy"}
|
||||||
|
subtitle={copied === "ok" ? undefined : "The clipboard is not available here."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button kind="secondary" onClick={() => void run()} disabled={running}>
|
||||||
|
Run again
|
||||||
|
</Button>
|
||||||
|
<Button kind="secondary" onClick={() => void copy()} disabled={!report}>
|
||||||
|
Copy report
|
||||||
|
</Button>
|
||||||
|
<Button kind="primary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ComposedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { MenuButton, MenuItem, OverflowMenu, OverflowMenuItem } from "@carbon/react";
|
||||||
|
import { ChevronDown } from "@carbon/icons-react";
|
||||||
|
import type { ExportMode } from "../lib/filename";
|
||||||
|
import { EXPORT_MODE_LABEL, saveExportMode } from "../lib/exportMode";
|
||||||
|
|
||||||
|
const MODES: ExportMode[] = ["searchable", "flattened"];
|
||||||
|
|
||||||
|
/** "Export PDF" menu for an issued invoice. Choosing a mode also makes it the remembered one. */
|
||||||
|
export default function ExportButton({ onExport, disabled }: { onExport: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<MenuButton label="Export PDF" kind="tertiary" disabled={disabled} menuAlignment="bottom-start">
|
||||||
|
{MODES.map((mode) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
label={EXPORT_MODE_LABEL[mode]}
|
||||||
|
onClick={() => {
|
||||||
|
saveExportMode(mode);
|
||||||
|
onExport(mode);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MenuButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The format the primary "Issue & export" action uses; a small menu next to that button. */
|
||||||
|
export function ExportModePicker({ mode, onChange, disabled }: { mode: ExportMode; onChange: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<OverflowMenu
|
||||||
|
aria-label="Choose the PDF format for Issue & export"
|
||||||
|
iconDescription={`PDF format: ${EXPORT_MODE_LABEL[mode]}`}
|
||||||
|
renderIcon={ChevronDown}
|
||||||
|
flipped
|
||||||
|
size="lg"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<OverflowMenuItem
|
||||||
|
key={m}
|
||||||
|
itemText={m === mode ? `${EXPORT_MODE_LABEL[m]} (selected)` : EXPORT_MODE_LABEL[m]}
|
||||||
|
onClick={() => {
|
||||||
|
saveExportMode(m);
|
||||||
|
onChange(m);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</OverflowMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, InlineLoading, InlineNotification } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { InvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
|
import FlattenProgressModal from "./FlattenProgressModal";
|
||||||
|
|
||||||
|
/** Progress modal, spinner, success and error notices for one `useInvoiceExport`. */
|
||||||
|
export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
||||||
|
const { busy, progress, request, error, result } = exp;
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
useEffect(() => setActionError(null), [result]);
|
||||||
|
|
||||||
|
const flattenFailed = request?.mode === "flattened" && error?.flatten === true;
|
||||||
|
const modalOpen = request?.mode === "flattened" && ((busy && progress?.phase !== "saving") || flattenFailed);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<void>) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FlattenProgressModal
|
||||||
|
open={modalOpen}
|
||||||
|
progress={progress}
|
||||||
|
error={flattenFailed ? error!.message : null}
|
||||||
|
onCancel={exp.cancel}
|
||||||
|
onRetry={exp.retry}
|
||||||
|
onExportSearchable={exp.exportSearchableInstead}
|
||||||
|
onClose={exp.dismiss}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
|
||||||
|
|
||||||
|
{error && !flattenFailed ? (
|
||||||
|
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}>
|
||||||
|
<div className="voiced-notice-actions">
|
||||||
|
<Button kind="ghost" size="sm" onClick={exp.retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</InlineNotification>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<>
|
||||||
|
<InlineNotification
|
||||||
|
kind="success"
|
||||||
|
lowContrast
|
||||||
|
title={`Saved to ${result.path}`}
|
||||||
|
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
|
||||||
|
onCloseButtonClick={exp.dismiss}
|
||||||
|
>
|
||||||
|
<div className="voiced-notice-actions">
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||||
|
Open file
|
||||||
|
</Button>
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
|
||||||
|
Show in folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</InlineNotification>
|
||||||
|
{result.identicalToIssued === false ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="Layout differs from the issued invoice"
|
||||||
|
subtitle="The current template lays this invoice out differently from the PDF issued. The archived original is unchanged; use Export PDF to get it."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{result.archiveError ? (
|
||||||
|
<InlineNotification kind="warning" lowContrast hideCloseButton title="This invoice is still not archived" subtitle={result.archiveError} />
|
||||||
|
) : null}
|
||||||
|
{actionError ? <InlineNotification kind="error" lowContrast title="Could not open the file" subtitle={actionError} onCloseButtonClick={() => setActionError(null)} /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Button, ComposedModal, InlineNotification, ModalBody, ModalFooter, ModalHeader, ProgressBar } from "@carbon/react";
|
||||||
|
import type { ExportProgress } from "../lib/exportFlow";
|
||||||
|
|
||||||
|
export interface FlattenProgressModalProps {
|
||||||
|
open: boolean;
|
||||||
|
progress: ExportProgress | null;
|
||||||
|
/** The flatten failed: show it with Retry and "Export searchable instead". */
|
||||||
|
error: string | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onExportSearchable: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(p: ExportProgress | null): { label: string; value?: number; max: number } {
|
||||||
|
if (p?.phase === "flattening" && p.page && p.pages) {
|
||||||
|
// The page being rendered counts as in progress, so the bar shows the pages finished before it.
|
||||||
|
return { label: `Rendering page ${p.page} of ${p.pages}…`, value: p.page - 1, max: p.pages };
|
||||||
|
}
|
||||||
|
if (p?.phase === "assembling") return { label: "Building the PDF…", value: 1, max: 1 };
|
||||||
|
return { label: "Preparing the invoice…", max: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlattenProgressModal({ open, progress, error, onCancel, onRetry, onExportSearchable, onClose }: FlattenProgressModalProps) {
|
||||||
|
const { label, value, max } = describe(progress);
|
||||||
|
return (
|
||||||
|
<ComposedModal open={open} size="sm" preventCloseOnClickOutside onClose={() => (error ? onClose() : onCancel())} aria-label="Flattened PDF export">
|
||||||
|
<ModalHeader title="Flattened PDF" label="Export" />
|
||||||
|
<ModalBody hasForm={false}>
|
||||||
|
{error ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="error"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="The flattened PDF could not be made"
|
||||||
|
subtitle={`${error} Nothing was saved, and the resolution was not lowered.`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProgressBar label={label} value={value} max={max} helperText="Each page is drawn at 300 DPI; this takes a few seconds per page." />
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
{error ? (
|
||||||
|
// Three direct children: Carbon lays a three-button footer out differently from a two-button one.
|
||||||
|
<ModalFooter>
|
||||||
|
<Button kind="secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button kind="tertiary" onClick={onExportSearchable}>
|
||||||
|
Export searchable instead
|
||||||
|
</Button>
|
||||||
|
<Button kind="primary" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
) : (
|
||||||
|
<ModalFooter>
|
||||||
|
<Button kind="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
)}
|
||||||
|
</ComposedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { UNIT_LABELS } from "../lib/types";
|
|
||||||
import { computeLineAmount } from "../lib/invoice";
|
|
||||||
import { formatAmount, formatDate } from "../lib/format";
|
|
||||||
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
|
||||||
|
|
||||||
const money = (n: number) => `₹${formatAmount(n)}`;
|
|
||||||
|
|
||||||
/** On-screen preview that mirrors the exported PDF layout. */
|
|
||||||
export function InvoicePreview(props: InvoicePdfProps) {
|
|
||||||
const {
|
|
||||||
vendor,
|
|
||||||
bank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri,
|
|
||||||
number,
|
|
||||||
invoiceDate,
|
|
||||||
dueDate,
|
|
||||||
clientName,
|
|
||||||
clientAddress,
|
|
||||||
clientGstin,
|
|
||||||
poNumber,
|
|
||||||
placeOfSupplyStateCode,
|
|
||||||
items,
|
|
||||||
subtotal,
|
|
||||||
discount,
|
|
||||||
taxType,
|
|
||||||
taxRate,
|
|
||||||
cgst,
|
|
||||||
sgst,
|
|
||||||
igst,
|
|
||||||
total,
|
|
||||||
amountInWords,
|
|
||||||
notes,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const isTaxInvoice = taxType !== "none";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inv-preview-wrap">
|
|
||||||
<div className="inv-paper">
|
|
||||||
<div className="inv-top">
|
|
||||||
<div className="inv-vendor">
|
|
||||||
{logoDataUri ? <img className="inv-vendor__logo" src={logoDataUri} alt="" /> : null}
|
|
||||||
<div>
|
|
||||||
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
|
|
||||||
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
|
|
||||||
{vendor.vendorEmail ? <p className="inv-vendor__line">{vendor.vendorEmail}</p> : null}
|
|
||||||
{vendor.vendorPhone ? <p className="inv-vendor__line">{vendor.vendorPhone}</p> : null}
|
|
||||||
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
|
|
||||||
{vendor.vendorGstin ? <p className="inv-vendor__line">GSTIN: {vendor.vendorGstin}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inv-meta">
|
|
||||||
<h3 className="inv-meta__title">{isTaxInvoice ? "Tax Invoice" : "Invoice"}</h3>
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Invoice No.</span>
|
|
||||||
<span className="inv-meta__value">{number}</span>
|
|
||||||
</div>
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Date</span>
|
|
||||||
<span className="inv-meta__value">{formatDate(invoiceDate)}</span>
|
|
||||||
</div>
|
|
||||||
{dueDate ? (
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Due Date</span>
|
|
||||||
<span className="inv-meta__value">{formatDate(dueDate)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inv-split">
|
|
||||||
<div className="inv-block" style={{ maxWidth: "60%" }}>
|
|
||||||
<p className="inv-block__label">Billed To</p>
|
|
||||||
<div className="inv-block__strong">{clientName || "—"}</div>
|
|
||||||
{clientAddress ? <div style={{ whiteSpace: "pre-line" }}>{clientAddress}</div> : null}
|
|
||||||
{clientGstin ? <div>GSTIN: {clientGstin}</div> : null}
|
|
||||||
{placeOfSupplyStateCode ? <div>Place of Supply: {placeOfSupplyStateCode}</div> : null}
|
|
||||||
</div>
|
|
||||||
{poNumber ? (
|
|
||||||
<div className="inv-block">
|
|
||||||
<p className="inv-block__label">PO Number</p>
|
|
||||||
<div className="inv-block__strong">{poNumber}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="inv-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Description</th>
|
|
||||||
<th className="num">Unit Price</th>
|
|
||||||
<th className="num">Qty</th>
|
|
||||||
<th className="num">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<tr key={item.id ?? index}>
|
|
||||||
<td>{item.description || "—"}</td>
|
|
||||||
<td className="num">
|
|
||||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="num">{item.mode === "rate" ? item.quantity : 1}</td>
|
|
||||||
<td className="num">{money(computeLineAmount(item))}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="inv-totals">
|
|
||||||
<div className="inv-totals__table">
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span>{money(subtotal)}</span>
|
|
||||||
</div>
|
|
||||||
{discount > 0 ? (
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>Discount</span>
|
|
||||||
<span>- {money(discount)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{taxType === "cgst_sgst" ? (
|
|
||||||
<>
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>CGST ({(taxRate / 2).toFixed(2)}%)</span>
|
|
||||||
<span>{money(cgst)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>SGST ({(taxRate / 2).toFixed(2)}%)</span>
|
|
||||||
<span>{money(sgst)}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{taxType === "igst" ? (
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>IGST ({taxRate.toFixed(2)}%)</span>
|
|
||||||
<span>{money(igst)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="inv-totals__row inv-totals__row--grand">
|
|
||||||
<span>Total</span>
|
|
||||||
<span>{money(total)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="inv-words">Amount in words: {amountInWords}</p>
|
|
||||||
|
|
||||||
{notes ? (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<p className="inv-block__label">Notes</p>
|
|
||||||
<div style={{ whiteSpace: "pre-line" }}>{notes}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="inv-bottom">
|
|
||||||
<div className="inv-block" style={{ maxWidth: "55%" }}>
|
|
||||||
{bank ? (
|
|
||||||
<>
|
|
||||||
<p className="inv-block__label">Pay To</p>
|
|
||||||
<div className="inv-block__strong">{bank.accountName || vendor.vendorName}</div>
|
|
||||||
<div>{bank.bankName}</div>
|
|
||||||
{bank.accountNo ? <div>Account No.: {bank.accountNo}</div> : null}
|
|
||||||
{bank.branch ? <div>Branch: {bank.branch}</div> : null}
|
|
||||||
{bank.ifsc ? <div>IFSC: {bank.ifsc}</div> : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="inv-signature">
|
|
||||||
{signatureDataUri ? (
|
|
||||||
<>
|
|
||||||
<img className="inv-signature__img" src={signatureDataUri} alt="Signature" />
|
|
||||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
|
||||||
<p className="inv-fineprint">Computer-generated invoice; no signature is required.</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inv-footer">
|
|
||||||
<span>{vendor.vendorName}</span>
|
|
||||||
<span>Generated with Voiced — a Bongbetic product</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { Settings } from "../lib/types";
|
||||||
|
import type { MarginPreset, PageId } from "../pdf/engine/geometry";
|
||||||
|
import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
|
|
||||||
|
const PAGES: { id: PageId; label: string }[] = [
|
||||||
|
{ id: "A4", label: "A4" },
|
||||||
|
{ id: "LETTER", label: "US Letter" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARGINS: { id: MarginPreset; label: string }[] = [
|
||||||
|
{ id: "template", label: "Template default" },
|
||||||
|
{ id: "narrow", label: "Narrow (0.5″)" },
|
||||||
|
{ id: "normal", label: "Normal (0.75″)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefs: RenderPrefsV1;
|
||||||
|
onChange: (prefs: RenderPrefsV1) => void;
|
||||||
|
/** Live settings; `renderPrefs` is the saved default. */
|
||||||
|
settings: Settings;
|
||||||
|
/** Called after the default was saved so the app reloads its settings. */
|
||||||
|
onSaved: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Where a TemplatePicker goes once there is more than one template. */
|
||||||
|
templateSlot?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */
|
||||||
|
export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled, templateSlot }: Props) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const saved = prefsToJson(parsePrefs(settings.renderPrefs));
|
||||||
|
const isDefault = prefsToJson(prefs) === saved;
|
||||||
|
|
||||||
|
const saveDefault = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.saveSettings({ ...settings, renderPrefs: prefsToJson(prefs) });
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-page-setup">
|
||||||
|
<div className="voiced-page-setup__row">
|
||||||
|
{templateSlot}
|
||||||
|
<div className="voiced-page-setup__field">
|
||||||
|
<span className="cds--label">Page size</span>
|
||||||
|
<ContentSwitcher
|
||||||
|
size="md"
|
||||||
|
selectedIndex={Math.max(0, PAGES.findIndex((p) => p.id === prefs.pageSize))}
|
||||||
|
onChange={(e) => !disabled && onChange({ ...prefs, pageSize: PAGES[e.index ?? 0].id })}
|
||||||
|
>
|
||||||
|
{PAGES.map((p) => (
|
||||||
|
<Switch key={p.id} name={p.id} text={p.label} disabled={disabled} />
|
||||||
|
))}
|
||||||
|
</ContentSwitcher>
|
||||||
|
</div>
|
||||||
|
<div className="voiced-page-setup__field">
|
||||||
|
<Select
|
||||||
|
id="page-margins"
|
||||||
|
labelText="Margins"
|
||||||
|
size="md"
|
||||||
|
disabled={disabled}
|
||||||
|
value={prefs.margins}
|
||||||
|
onChange={(e) => onChange({ ...prefs, margins: e.target.value as MarginPreset })}
|
||||||
|
>
|
||||||
|
{MARGINS.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="voiced-page-setup__footer">
|
||||||
|
{saving ? (
|
||||||
|
<InlineLoading status="active" description="Saving…" />
|
||||||
|
) : (
|
||||||
|
<Button kind="tertiary" size="sm" disabled={isDefault} onClick={() => void saveDefault()}>
|
||||||
|
{isDefault ? "Saved as default" : "Save as default"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className="voiced-muted voiced-page-setup__note">
|
||||||
|
{error ?? "This choice is frozen into each invoice when it is issued."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Button, ComposedModal, ContentSwitcher, InlineLoading, InlineNotification, ModalBody, ModalHeader, Popover, PopoverContent, Switch, Tag } from "@carbon/react";
|
||||||
|
import { Maximize, Warning } from "@carbon/icons-react";
|
||||||
|
import { cancelPageRender, closePdf, isRenderCancelled, openPdf, renderPageToCanvas, type PDFDocumentProxy } from "../lib/pdfjs";
|
||||||
|
import type { AuditIssue } from "../pdf/engine/audit";
|
||||||
|
|
||||||
|
export interface PdfPreviewProps {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
pages: number;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
rendering: boolean;
|
||||||
|
/** "main": rendering runs on the main thread, so the preview only updates when typing pauses. */
|
||||||
|
mode: "worker" | "main";
|
||||||
|
error: string | null;
|
||||||
|
/** Adds a "Focus preview" button (the modal itself is built in). */
|
||||||
|
onFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Zoom = "fit" | "100" | "150";
|
||||||
|
const ZOOMS: { id: Zoom; label: string }[] = [
|
||||||
|
{ id: "fit", label: "Fit width" },
|
||||||
|
{ id: "100", label: "100%" },
|
||||||
|
{ id: "150", label: "150%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Gap between pages and padding around them, in CSS px (matches .voiced-pdf-preview__pages). */
|
||||||
|
const PAGE_GAP = 16;
|
||||||
|
const PAD = 16;
|
||||||
|
const MAX_LIVE_CANVASES = 3;
|
||||||
|
/** Backing store is never below 2x the CSS size: at fit-width the text would be blurry at 1x. */
|
||||||
|
const MIN_BACKING = 2;
|
||||||
|
const CSS_PX_PER_PT = 96 / 72;
|
||||||
|
|
||||||
|
function useDocument(bytes: Uint8Array | null) {
|
||||||
|
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const current = useRef<PDFDocumentProxy | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bytes) return;
|
||||||
|
let stale = false;
|
||||||
|
openPdf(bytes).then(
|
||||||
|
(opened) => {
|
||||||
|
if (stale) {
|
||||||
|
void closePdf(opened);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = current.current;
|
||||||
|
current.current = opened;
|
||||||
|
setDoc(opened);
|
||||||
|
setLoadError(null);
|
||||||
|
// The old canvases are plain bitmaps, so the old document can go once the slots re-render.
|
||||||
|
if (previous) setTimeout(() => void closePdf(previous), 500);
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!stale) setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [bytes]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (current.current) void closePdf(current.current);
|
||||||
|
current.current = null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { doc, loadError };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDebounced<T>(value: T, ms: number): T {
|
||||||
|
const [v, setV] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setV(value), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, ms]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProps {
|
||||||
|
doc: PDFDocumentProxy;
|
||||||
|
index: number;
|
||||||
|
live: boolean;
|
||||||
|
renderScale: number;
|
||||||
|
cssWidth: number;
|
||||||
|
cssHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page. A live slot renders into an offscreen canvas and swaps it in when done, so a re-render
|
||||||
|
* never blanks the page; a slot that is not live holds no canvas at all.
|
||||||
|
*/
|
||||||
|
function PageSlot({ doc, index, live, renderScale, cssWidth, cssHeight }: SlotProps) {
|
||||||
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const key = useRef({}).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = host.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!live) {
|
||||||
|
const old = el.firstElementChild as HTMLCanvasElement | null;
|
||||||
|
el.replaceChildren();
|
||||||
|
if (old) old.width = old.height = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dead = false;
|
||||||
|
const off = document.createElement("canvas");
|
||||||
|
off.className = "voiced-pdf-preview__canvas";
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const page = await doc.getPage(index + 1);
|
||||||
|
await renderPageToCanvas(page, off, renderScale, key);
|
||||||
|
if (dead) return;
|
||||||
|
const old = el.firstElementChild as HTMLCanvasElement | null;
|
||||||
|
el.replaceChildren(off);
|
||||||
|
if (old) old.width = old.height = 0;
|
||||||
|
} catch (err) {
|
||||||
|
if (!dead && !isRenderCancelled(err)) console.error("PDF page render failed", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
cancelPageRender(key);
|
||||||
|
};
|
||||||
|
}, [doc, index, live, renderScale, key]);
|
||||||
|
|
||||||
|
return <div ref={host} className="voiced-pdf-preview__page" data-page={index} style={{ width: cssWidth, height: cssHeight }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewerProps {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
/** Page count from the render, shown until the document has opened. */
|
||||||
|
pages: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zoom control, page indicator and the scrolling stack of page canvases. */
|
||||||
|
function PdfViewer({ bytes, pages, className }: ViewerProps) {
|
||||||
|
const { doc, loadError } = useDocument(bytes);
|
||||||
|
const [zoom, setZoom] = useState<Zoom>("fit");
|
||||||
|
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
const [fitWidth, setFitWidth] = useState(0);
|
||||||
|
const [live, setLive] = useState<number[]>([0]);
|
||||||
|
const [current, setCurrent] = useState(1);
|
||||||
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doc) return;
|
||||||
|
let stale = false;
|
||||||
|
void doc.getPage(1).then((p) => {
|
||||||
|
const v = p.getViewport({ scale: 1 });
|
||||||
|
if (!stale) setNatural({ w: v.width, h: v.height });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [doc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scroller.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setFitWidth(Math.max(0, el.clientWidth - 2 * PAD));
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pageCount = doc?.numPages ?? pages;
|
||||||
|
const cssWidth = natural ? (zoom === "fit" ? fitWidth : natural.w * CSS_PX_PER_PT * (Number(zoom) / 100)) : 0;
|
||||||
|
const cssHeight = natural ? (cssWidth * natural.h) / natural.w : 0;
|
||||||
|
const settledWidth = useDebounced(cssWidth, 150);
|
||||||
|
const renderScale = natural && settledWidth > 0 ? (settledWidth / natural.w) * Math.max(MIN_BACKING, window.devicePixelRatio || 1) : 0;
|
||||||
|
|
||||||
|
// Which pages are near the viewport; at most MAX_LIVE_CANVASES of them hold a canvas.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scroller.current;
|
||||||
|
if (!root || !doc || !natural) return;
|
||||||
|
const visible = new Set<number>();
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const n = Number((e.target as HTMLElement).dataset.page);
|
||||||
|
if (e.isIntersecting) visible.add(n);
|
||||||
|
else visible.delete(n);
|
||||||
|
}
|
||||||
|
const next = [...visible].sort((a, b) => a - b).slice(0, MAX_LIVE_CANVASES);
|
||||||
|
setLive((prev) => (prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next));
|
||||||
|
},
|
||||||
|
{ root, rootMargin: "200px 0px" },
|
||||||
|
);
|
||||||
|
root.querySelectorAll("[data-page]").forEach((el) => io.observe(el));
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [doc, natural, pageCount, cssHeight]);
|
||||||
|
|
||||||
|
// Pages are one size, so the page under the middle of the viewport follows from the scroll offset.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scroller.current;
|
||||||
|
if (!root || cssHeight <= 0) return;
|
||||||
|
let frame = 0;
|
||||||
|
const update = () => {
|
||||||
|
frame = 0;
|
||||||
|
const middle = root.scrollTop + root.clientHeight / 2 - PAD;
|
||||||
|
const n = Math.floor(middle / (cssHeight + PAGE_GAP)) + 1;
|
||||||
|
setCurrent(Math.min(Math.max(n, 1), Math.max(pageCount, 1)));
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
root.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener("scroll", onScroll);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [cssHeight, pageCount]);
|
||||||
|
|
||||||
|
const liveSet = useMemo(() => new Set(live), [live]);
|
||||||
|
const zoomIndex = ZOOMS.findIndex((z) => z.id === zoom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`voiced-pdf-viewer ${className ?? ""}`}>
|
||||||
|
<div className="voiced-pdf-viewer__bar">
|
||||||
|
<ContentSwitcher
|
||||||
|
size="sm"
|
||||||
|
className="voiced-pdf-viewer__zoom"
|
||||||
|
selectedIndex={zoomIndex}
|
||||||
|
onChange={(e) => setZoom(ZOOMS[e.index ?? 0].id)}
|
||||||
|
>
|
||||||
|
{ZOOMS.map((z) => (
|
||||||
|
<Switch key={z.id} name={z.id} text={z.label} />
|
||||||
|
))}
|
||||||
|
</ContentSwitcher>
|
||||||
|
<span className="voiced-pdf-viewer__page" aria-live="polite">
|
||||||
|
{pageCount > 0 ? `Page ${current} of ${pageCount}` : "No pages"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref={scroller} className="voiced-pdf-preview__scroll">
|
||||||
|
{loadError ? (
|
||||||
|
<InlineNotification lowContrast hideCloseButton kind="error" title="Cannot show the PDF" subtitle={loadError} />
|
||||||
|
) : null}
|
||||||
|
<div className="voiced-pdf-preview__pages">
|
||||||
|
{doc && natural && cssWidth > 0
|
||||||
|
? Array.from({ length: pageCount }, (_, i) => (
|
||||||
|
<PageSlot
|
||||||
|
key={i}
|
||||||
|
doc={doc}
|
||||||
|
index={i}
|
||||||
|
live={liveSet.has(i) && renderScale > 0}
|
||||||
|
renderScale={renderScale}
|
||||||
|
cssWidth={cssWidth}
|
||||||
|
cssHeight={cssHeight}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PdfPreview({ bytes, pages, issues, rendering, mode, error, onFocus }: PdfPreviewProps) {
|
||||||
|
const [issuesOpen, setIssuesOpen] = useState(false);
|
||||||
|
const [focusOpen, setFocusOpen] = useState(false);
|
||||||
|
const errors = issues.filter((i) => i.severity === "error").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-pdf-preview">
|
||||||
|
<div className="voiced-pdf-preview__status">
|
||||||
|
<div className="voiced-pdf-preview__chips">
|
||||||
|
{rendering ? <InlineLoading status="active" description="Rendering…" /> : null}
|
||||||
|
{mode === "main" ? (
|
||||||
|
<Tag type="gray" size="sm">
|
||||||
|
Preview paused while typing
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="voiced-pdf-preview__actions">
|
||||||
|
{issues.length > 0 ? (
|
||||||
|
<Popover open={issuesOpen} align="bottom-end" dropShadow onRequestClose={() => setIssuesOpen(false)}>
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={Warning}
|
||||||
|
aria-expanded={issuesOpen}
|
||||||
|
onClick={() => setIssuesOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{errors > 0 ? `Issues (${issues.length}, ${errors} error${errors === 1 ? "" : "s"})` : `Issues (${issues.length})`}
|
||||||
|
</Button>
|
||||||
|
<PopoverContent className="voiced-pdf-issues">
|
||||||
|
<ul className="voiced-pdf-issues__list">
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<li key={i} className="voiced-pdf-issues__item">
|
||||||
|
<div className="voiced-pdf-issues__head">
|
||||||
|
<Tag type={issue.severity === "error" ? "red" : "gray"} size="sm" className={issue.severity === "error" ? undefined : "voiced-tag--warn"}>
|
||||||
|
{issue.code}
|
||||||
|
</Tag>
|
||||||
|
<span className="voiced-muted">Page {issue.page}</span>
|
||||||
|
</div>
|
||||||
|
<p className="voiced-pdf-issues__message">{issue.message}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
hasIconOnly
|
||||||
|
renderIcon={Maximize}
|
||||||
|
iconDescription="Focus preview"
|
||||||
|
tooltipPosition="left"
|
||||||
|
disabled={!bytes}
|
||||||
|
onClick={() => {
|
||||||
|
setFocusOpen(true);
|
||||||
|
onFocus?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <InlineNotification lowContrast hideCloseButton kind="error" title="Preview failed" subtitle={error} /> : null}
|
||||||
|
|
||||||
|
{bytes ? (
|
||||||
|
<PdfViewer bytes={bytes} pages={pages} />
|
||||||
|
) : (
|
||||||
|
<div className="voiced-pdf-preview__empty">{rendering ? "Rendering the first preview…" : "The preview appears here."}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ComposedModal open={focusOpen} size="lg" className="voiced-pdf-focus" onClose={() => setFocusOpen(false)}>
|
||||||
|
<ModalHeader title="Invoice preview" closeModal={() => setFocusOpen(false)} />
|
||||||
|
<ModalBody hasScrollingContent={false}>{focusOpen && bytes ? <PdfViewer bytes={bytes} pages={pages} className="voiced-pdf-viewer--focus" /> : null}</ModalBody>
|
||||||
|
</ComposedModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { createExportDeps } from "../lib/exportDeps";
|
||||||
|
import { exportInvoice, isExportCancelled, isFlattenFailure, type ExportProgress, type ExportResult, type ExportSource } from "../lib/exportFlow";
|
||||||
|
import type { ExportMode } from "../lib/filename";
|
||||||
|
import type { BankAccount, Invoice, Settings } from "../lib/types";
|
||||||
|
|
||||||
|
export interface ExportRequestState {
|
||||||
|
invoice: Invoice;
|
||||||
|
mode: ExportMode;
|
||||||
|
source: ExportSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportFailure {
|
||||||
|
message: string;
|
||||||
|
/** The flatten itself failed: the modal offers Retry and "Export searchable instead". */
|
||||||
|
flatten: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceExport {
|
||||||
|
busy: boolean;
|
||||||
|
progress: ExportProgress | null;
|
||||||
|
request: ExportRequestState | null;
|
||||||
|
error: ExportFailure | null;
|
||||||
|
result: ExportResult | null;
|
||||||
|
start(invoice: Invoice, mode: ExportMode, source: ExportSource): Promise<ExportResult | null>;
|
||||||
|
cancel(): void;
|
||||||
|
retry(): void;
|
||||||
|
exportSearchableInstead(): void;
|
||||||
|
dismiss(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs exports one at a time and keeps what the UI shows: progress, the last result, the last error and
|
||||||
|
* the request, so Retry and "Export searchable instead" can repeat it. `onInvoiceUpdated` hears about an
|
||||||
|
* invoice that was archived by the export.
|
||||||
|
*/
|
||||||
|
export function useInvoiceExport(opts: { settings: Settings; bank: BankAccount | null; onInvoiceUpdated?: (invoice: Invoice) => void }): InvoiceExport {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<ExportProgress | null>(null);
|
||||||
|
const [request, setRequest] = useState<ExportRequestState | null>(null);
|
||||||
|
const [error, setError] = useState<ExportFailure | null>(null);
|
||||||
|
const [result, setResult] = useState<ExportResult | null>(null);
|
||||||
|
const controller = useRef<AbortController | null>(null);
|
||||||
|
const running = useRef(false);
|
||||||
|
|
||||||
|
const latest = useRef(opts);
|
||||||
|
latest.current = opts;
|
||||||
|
|
||||||
|
const start = useCallback(async (invoice: Invoice, mode: ExportMode, source: ExportSource) => {
|
||||||
|
if (running.current) return null;
|
||||||
|
running.current = true;
|
||||||
|
const ctl = new AbortController();
|
||||||
|
controller.current = ctl;
|
||||||
|
setRequest({ invoice, mode, source });
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setProgress({ phase: "rendering" });
|
||||||
|
try {
|
||||||
|
const deps = createExportDeps({ settings: latest.current.settings, bank: latest.current.bank });
|
||||||
|
const res = await exportInvoice({ invoice, mode, source, signal: ctl.signal, onProgress: setProgress }, deps);
|
||||||
|
if (res) {
|
||||||
|
setResult(res);
|
||||||
|
if (res.invoice !== invoice) latest.current.onInvoiceUpdated?.(res.invoice);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
if (!isExportCancelled(e)) setError({ message: e instanceof Error ? e.message : String(e), flatten: isFlattenFailure(e) });
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
running.current = false;
|
||||||
|
controller.current = null;
|
||||||
|
setBusy(false);
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => controller.current?.abort(), []);
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
}, []);
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
if (request) void start(request.invoice, request.mode, request.source);
|
||||||
|
}, [request, start]);
|
||||||
|
const exportSearchableInstead = useCallback(() => {
|
||||||
|
if (request) void start(request.invoice, "searchable", request.source);
|
||||||
|
}, [request, start]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss }),
|
||||||
|
[busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "../lib/renderClient";
|
||||||
|
import type { AuditIssue } from "../pdf/engine/audit";
|
||||||
|
import type { RenderModel } from "../pdf/model/build";
|
||||||
|
import type { RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
|
import { Superseded } from "../pdf/render/client";
|
||||||
|
|
||||||
|
/** Typing pauses this long before the model is re-rendered; the render itself takes about 1 s. */
|
||||||
|
export const MODEL_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
|
export interface InvoicePdfState {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
pages: number;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
rendering: boolean;
|
||||||
|
error: string | null;
|
||||||
|
mode: "worker" | "main";
|
||||||
|
lastMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL: InvoicePdfState = { bytes: null, pages: 0, issues: [], rendering: false, error: null, mode: "worker", lastMs: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live PDF for a render model. Model changes are debounced; changes to the prefs render at once. A new
|
||||||
|
* request cancels the one in flight, the previous bytes stay in place while the next render runs, and
|
||||||
|
* a null model never renders (the last PDF stays). Pass a memoised model: a new object means a change.
|
||||||
|
*/
|
||||||
|
export function useInvoicePdf({ model, prefs }: { model: RenderModel | null; prefs: RenderPrefsV1 }): InvoicePdfState {
|
||||||
|
const [state, setState] = useState<InvoicePdfState>(INITIAL);
|
||||||
|
const latestJob = useRef(0);
|
||||||
|
const lastPrefsKey = useRef<string | null>(null);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const prefsKey = JSON.stringify(prefs);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model) return;
|
||||||
|
const prefsChanged = lastPrefsKey.current !== prefsKey;
|
||||||
|
lastPrefsKey.current = prefsKey;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
const id = nextRenderJobId();
|
||||||
|
latestJob.current = id;
|
||||||
|
setState((s) => ({ ...s, rendering: true }));
|
||||||
|
const client = getRenderClient();
|
||||||
|
client.render({ id, model, prefs: JSON.parse(prefsKey) as RenderPrefsV1 }).then(
|
||||||
|
(res) => {
|
||||||
|
if (!mounted.current || latestJob.current !== id) return;
|
||||||
|
setState({ bytes: res.bytes, pages: res.pages, issues: res.issues, rendering: false, error: null, mode: client.mode, lastMs: res.ms });
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!mounted.current || latestJob.current !== id) return;
|
||||||
|
// Superseded while still the newest here means another caller (export) took the worker; keep
|
||||||
|
// the old PDF and let the next model or prefs change render again.
|
||||||
|
const error = err instanceof Superseded ? null : err instanceof Error ? err.message : String(err);
|
||||||
|
setState((s) => ({ ...s, rendering: false, error: error ?? s.error, mode: client.mode }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prefsChanged) start();
|
||||||
|
const timer = prefsChanged ? undefined : setTimeout(start, MODEL_DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [model, prefsKey]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
+23
-3
@@ -10,6 +10,13 @@ import type {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
export interface ArchiveStatus {
|
||||||
|
archived: boolean;
|
||||||
|
sha256?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
archivedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getSettings: () => invoke<Settings>("get_settings"),
|
getSettings: () => invoke<Settings>("get_settings"),
|
||||||
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
||||||
@@ -28,7 +35,7 @@ export const api = {
|
|||||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||||
|
|
||||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||||
issueInvoice: (input: InvoiceInput, renderPrefs: Record<string, unknown>) =>
|
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||||
cancelInvoice: (id: number, reason: string) =>
|
cancelInvoice: (id: number, reason: string) =>
|
||||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||||
@@ -47,6 +54,19 @@ export const api = {
|
|||||||
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
||||||
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
||||||
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
||||||
saveBinaryFile: (path: string, dataBase64: string) =>
|
|
||||||
invoke<void>("save_binary_file", { path, dataBase64 }),
|
// Raw-body commands: the payload is the bytes themselves (no base64); metadata goes in headers.
|
||||||
|
// Header values must be visible ASCII, hence the encodeURIComponent on paths.
|
||||||
|
archivePdf: (invoiceId: number, bytes: Uint8Array, fingerprint: string) =>
|
||||||
|
invoke<string>("archive_pdf", bytes, {
|
||||||
|
headers: { "x-invoice-id": String(invoiceId), "x-fingerprint": fingerprint },
|
||||||
|
}),
|
||||||
|
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
|
||||||
|
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
|
||||||
|
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
|
||||||
|
writeExportFile: (path: string, bytes: Uint8Array) =>
|
||||||
|
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
|
||||||
|
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||||
|
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||||
|
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { flattenPages, type CanvasLike, type FlattenResult, type PdfDocLike } from "../pdf/flatten";
|
||||||
|
|
||||||
|
/** Lets the browser paint and handle input; falls back to a timer when rAF is paused (hidden window). */
|
||||||
|
export const yieldToBrowser = (): Promise<void> =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (!done) {
|
||||||
|
done = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(finish, 50);
|
||||||
|
requestAnimationFrame(() => setTimeout(finish, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** JPEG bytes of a canvas via the browser's encoder. */
|
||||||
|
export function encodeCanvasJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(canvas as unknown as HTMLCanvasElement).toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) return reject(new Error("the browser could not encode the page image (out of memory?)"));
|
||||||
|
blob.arrayBuffer().then((buf) => resolve(new Uint8Array(buf)), reject);
|
||||||
|
},
|
||||||
|
"image/jpeg",
|
||||||
|
quality,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserFlattenOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (page: number, pages: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattens PDF bytes at 300 DPI with pdf.js and a DOM canvas; opens and closes the document itself. */
|
||||||
|
export async function flattenInBrowser(bytes: Uint8Array, opts: BrowserFlattenOptions = {}): Promise<FlattenResult> {
|
||||||
|
const { openPdf, closePdf } = await import("./pdfjs");
|
||||||
|
const doc = await openPdf(bytes);
|
||||||
|
try {
|
||||||
|
return await flattenPages({
|
||||||
|
pdf: doc as unknown as PdfDocLike,
|
||||||
|
createCanvas: (width, height) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
return canvas as unknown as CanvasLike;
|
||||||
|
},
|
||||||
|
encodeJpeg: encodeCanvasJpeg,
|
||||||
|
yieldToUi: yieldToBrowser,
|
||||||
|
signal: opts.signal,
|
||||||
|
onProgress: opts.onProgress,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await closePdf(doc).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { flattenInBrowser } from "./browserFlatten";
|
||||||
|
import type { ExportDeps } from "./exportFlow";
|
||||||
|
import { renderInvoicePdf } from "./pdf";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
|
import type { BankAccount, Settings } from "./types";
|
||||||
|
|
||||||
|
/** The real collaborators of the export flow: Tauri commands, the render client, pdf.js and the save dialog. */
|
||||||
|
export function createExportDeps(ctx: { settings: Settings; bank: BankAccount | null }): ExportDeps {
|
||||||
|
return {
|
||||||
|
issueInvoice: (input, renderPrefs) => api.issueInvoice(input, renderPrefs),
|
||||||
|
render: async (invoice) => {
|
||||||
|
const { bytes, fingerprint } = await renderInvoicePdf(invoice, ctx.settings, ctx.bank);
|
||||||
|
return { bytes, fingerprint };
|
||||||
|
},
|
||||||
|
archivePdf: api.archivePdf,
|
||||||
|
readArchive: api.readArchive,
|
||||||
|
flatten: (bytes, opts) => flattenInBrowser(bytes, opts),
|
||||||
|
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
|
||||||
|
getLastExportDir: api.getLastExportDir,
|
||||||
|
save: ({ defaultPath }) => save({ defaultPath, filters: [{ name: "PDF document", extensions: ["pdf"] }] }),
|
||||||
|
writeExportFile: api.writeExportFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { FlattenCancelled, FlattenError } from "../pdf/flatten";
|
||||||
|
import { exportInvoice, ExportError, isFlattenFailure, issueAndArchive, renderAndArchive, type ExportDeps, type ExportProgress } from "./exportFlow";
|
||||||
|
import type { Invoice, InvoiceInput } from "./types";
|
||||||
|
|
||||||
|
const bytes = (s: string) => new TextEncoder().encode(s);
|
||||||
|
const text = (b: Uint8Array) => new TextDecoder().decode(b);
|
||||||
|
|
||||||
|
const invoice = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: 7,
|
||||||
|
number: "INV/2026-007",
|
||||||
|
clientName: "Acme Learning",
|
||||||
|
invoiceDate: "2026-09-15",
|
||||||
|
status: "issued",
|
||||||
|
renderPrefs: '{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"template"}',
|
||||||
|
archivedPdfSha256: "a".repeat(64),
|
||||||
|
archivedFingerprint: "fp-issued",
|
||||||
|
items: [],
|
||||||
|
...over,
|
||||||
|
}) as Invoice;
|
||||||
|
|
||||||
|
function fakes(over: Partial<ExportDeps> = {}) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const written: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||||
|
const deps: ExportDeps = {
|
||||||
|
issueInvoice: vi.fn(async () => (calls.push("issue"), invoice({ archivedPdfSha256: null, archivedFingerprint: null }))),
|
||||||
|
render: vi.fn(async () => (calls.push("render"), { bytes: bytes("RENDERED"), fingerprint: "fp-now" })),
|
||||||
|
archivePdf: vi.fn(async () => (calls.push("archive"), "b".repeat(64))),
|
||||||
|
readArchive: vi.fn(async () => (calls.push("readArchive"), bytes("ARCHIVED"))),
|
||||||
|
flatten: vi.fn(async (b, o) => {
|
||||||
|
calls.push("flatten");
|
||||||
|
o.onProgress?.(1, 2);
|
||||||
|
o.onProgress?.(2, 2);
|
||||||
|
return {
|
||||||
|
pages: [1, 2].map((n) => ({ jpeg: bytes(`${text(b)}-jpeg${n}`), widthPt: 595, heightPt: 842, pxWidth: 2480, pxHeight: 3508, renderMs: 1, encodeMs: 1 })),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
assemble: vi.fn(async (pages, title) => (calls.push("assemble"), bytes(`FLAT[${title}|${pages.map((p: { jpeg: Uint8Array }) => text(p.jpeg)).join(",")}]`))),
|
||||||
|
getLastExportDir: vi.fn(async () => "/home/u/Documents"),
|
||||||
|
save: vi.fn(async ({ defaultPath }) => (calls.push("save"), defaultPath)),
|
||||||
|
writeExportFile: vi.fn(async (path, b) => (calls.push("write"), written.push({ path, bytes: b }), path)),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
return { deps, calls, written };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issueAndArchive / renderAndArchive", () => {
|
||||||
|
it("issues, renders the issued invoice and archives it with the layout fingerprint", async () => {
|
||||||
|
const { deps, calls } = fakes();
|
||||||
|
const out = await issueAndArchive({} as InvoiceInput, { pageSize: "LETTER" }, deps);
|
||||||
|
expect(calls).toEqual(["issue", "render", "archive"]);
|
||||||
|
expect(deps.issueInvoice).toHaveBeenCalledWith({}, { pageSize: "LETTER" });
|
||||||
|
// The render is of the invoice the backend returned (so its frozen prefs), not of the form.
|
||||||
|
expect((deps.render as ReturnType<typeof vi.fn>).mock.calls[0][0].renderPrefs).toContain("LETTER");
|
||||||
|
expect(deps.archivePdf).toHaveBeenCalledWith(7, bytes("RENDERED"), "fp-now");
|
||||||
|
expect(out).toMatchObject({ archived: true });
|
||||||
|
expect(out.invoice).toMatchObject({ archivedPdfSha256: "b".repeat(64), archivedFingerprint: "fp-now" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a render failure keeps the invoice and reports the error", async () => {
|
||||||
|
const { deps } = fakes({ render: vi.fn(async () => Promise.reject(new Error("worker died"))) });
|
||||||
|
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
|
||||||
|
expect(out.archived).toBe(false);
|
||||||
|
expect(out.invoice.id).toBe(7);
|
||||||
|
expect(out.error).toContain("worker died");
|
||||||
|
expect(deps.archivePdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an archive failure keeps the invoice and reports the error", async () => {
|
||||||
|
const { deps } = fakes({ archivePdf: vi.fn(async () => Promise.reject("disk full")) });
|
||||||
|
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
|
||||||
|
expect(out).toMatchObject({ archived: false });
|
||||||
|
expect(out.error).toContain("disk full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed issue throws: nothing was issued, nothing is rendered", async () => {
|
||||||
|
const { deps } = fakes({ issueInvoice: vi.fn(async () => Promise.reject(new Error("series locked"))) });
|
||||||
|
await expect(issueAndArchive({} as InvoiceInput, {}, deps)).rejects.toThrow("series locked");
|
||||||
|
expect(deps.render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renderAndArchive retries for an invoice issued without an archive", async () => {
|
||||||
|
const { deps } = fakes();
|
||||||
|
const out = await renderAndArchive(invoice({ archivedPdfSha256: null, archivedFingerprint: null }), deps);
|
||||||
|
expect(out.archived).toBe(true);
|
||||||
|
expect(out.invoice.archivedPdfSha256).toBe("b".repeat(64));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportInvoice", () => {
|
||||||
|
it("original: exports the archived bytes exactly and never renders", async () => {
|
||||||
|
const { deps, written } = fakes();
|
||||||
|
const r = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps);
|
||||||
|
expect(deps.render).not.toHaveBeenCalled();
|
||||||
|
expect(deps.readArchive).toHaveBeenCalledWith(7);
|
||||||
|
expect(text(written[0].bytes)).toBe("ARCHIVED");
|
||||||
|
expect(r).toMatchObject({ mode: "searchable", path: "/home/u/Documents/INV-2026-007_Acme Learning_2026-09-15.pdf" });
|
||||||
|
expect(r?.identicalToIssued).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rerender: renders now (not the archive) and reports whether the layout matches the issued one", async () => {
|
||||||
|
const same = fakes({ render: vi.fn(async () => ({ bytes: bytes("NEW"), fingerprint: "fp-issued" })) });
|
||||||
|
const a = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, same.deps);
|
||||||
|
expect(same.deps.readArchive).not.toHaveBeenCalled();
|
||||||
|
expect(text(same.written[0].bytes)).toBe("NEW");
|
||||||
|
expect(a?.identicalToIssued).toBe(true);
|
||||||
|
|
||||||
|
const diff = fakes();
|
||||||
|
const b = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, diff.deps);
|
||||||
|
expect(b?.identicalToIssued).toBe(false);
|
||||||
|
// The archive is never touched by a re-render.
|
||||||
|
expect(diff.deps.archivePdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rerender of an invoice with nothing archived has nothing to compare with", async () => {
|
||||||
|
const { deps } = fakes();
|
||||||
|
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "rerender" }, deps);
|
||||||
|
expect(r && "identicalToIssued" in r).toBe(false);
|
||||||
|
expect(deps.archivePdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("not archived: renders, archives, then exports that render and returns the healed invoice", async () => {
|
||||||
|
const { deps, calls, written } = fakes();
|
||||||
|
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "original" }, deps);
|
||||||
|
expect(calls).toEqual(["render", "archive", "save", "write"]);
|
||||||
|
expect(text(written[0].bytes)).toBe("RENDERED");
|
||||||
|
expect(r?.invoice.archivedPdfSha256).toBe("b".repeat(64));
|
||||||
|
expect(r?.archiveError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("not archived and the archive fails: still exports the render and says so", async () => {
|
||||||
|
const { deps, written } = fakes({ archivePdf: vi.fn(async () => Promise.reject(new Error("read-only"))) });
|
||||||
|
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null }), mode: "searchable", source: "original" }, deps);
|
||||||
|
expect(text(written[0].bytes)).toBe("RENDERED");
|
||||||
|
expect(r?.archiveError).toContain("read-only");
|
||||||
|
expect(r?.invoice.archivedPdfSha256).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattened: flattens the chosen source, assembles with the invoice number as title, names the file -flat", async () => {
|
||||||
|
const { deps, written } = fakes();
|
||||||
|
const progress: ExportProgress[] = [];
|
||||||
|
const r = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", onProgress: (p) => progress.push(p) }, deps);
|
||||||
|
expect((deps.flatten as ReturnType<typeof vi.fn>).mock.calls[0][0]).toEqual(bytes("ARCHIVED"));
|
||||||
|
expect(text(written[0].bytes)).toBe("FLAT[INV/2026-007|ARCHIVED-jpeg1,ARCHIVED-jpeg2]");
|
||||||
|
expect(r?.path.endsWith("-flat.pdf")).toBe(true);
|
||||||
|
expect(progress.map((p) => p.phase)).toEqual(["rendering", "flattening", "flattening", "assembling", "saving"]);
|
||||||
|
expect(progress[1]).toMatchObject({ page: 1, pages: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattened rerender flattens the fresh render", async () => {
|
||||||
|
const { deps, written } = fakes();
|
||||||
|
await exportInvoice({ invoice: invoice(), mode: "flattened", source: "rerender" }, deps);
|
||||||
|
expect(text(written[0].bytes)).toContain("RENDERED-jpeg1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a flatten failure surfaces as FLATTEN_FAILED: no searchable fallback, nothing saved", async () => {
|
||||||
|
const { deps, calls } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenError(2, "out of memory"))) });
|
||||||
|
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(FlattenError);
|
||||||
|
expect(isFlattenFailure(err)).toBe(true);
|
||||||
|
expect(calls).not.toContain("save");
|
||||||
|
expect(calls).not.toContain("write");
|
||||||
|
expect(deps.flatten).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an assemble failure is also FLATTEN_FAILED", async () => {
|
||||||
|
const { deps } = fakes({ assemble: vi.fn(async () => Promise.reject(new Error("worker timed out"))) });
|
||||||
|
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(ExportError);
|
||||||
|
expect(err.code).toBe("FLATTEN_FAILED");
|
||||||
|
expect(err.message).toContain("worker timed out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel: an aborted signal stops the flow with FlattenCancelled and saves nothing", async () => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const { deps, calls } = fakes({
|
||||||
|
flatten: vi.fn(async (_b, o) => {
|
||||||
|
ctl.abort();
|
||||||
|
o.onProgress?.(1, 2);
|
||||||
|
return { pages: [] };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", signal: ctl.signal }, deps).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(FlattenCancelled);
|
||||||
|
expect(calls).not.toContain("assemble");
|
||||||
|
expect(calls).not.toContain("save");
|
||||||
|
|
||||||
|
const pre = new AbortController();
|
||||||
|
pre.abort();
|
||||||
|
const f2 = fakes();
|
||||||
|
await expect(exportInvoice({ invoice: invoice(), mode: "searchable", source: "original", signal: pre.signal }, f2.deps)).rejects.toBeInstanceOf(FlattenCancelled);
|
||||||
|
expect(f2.deps.readArchive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a FlattenCancelled thrown by flatten passes through unchanged", async () => {
|
||||||
|
const { deps } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenCancelled())) });
|
||||||
|
await expect(exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps)).rejects.toBeInstanceOf(FlattenCancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissing the save dialog resolves null and writes nothing", async () => {
|
||||||
|
const { deps, calls } = fakes({ save: vi.fn(async () => null) });
|
||||||
|
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps)).toBeNull();
|
||||||
|
expect(calls).not.toContain("write");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the dialog in the last folder, handles Windows folders, and survives a missing folder", async () => {
|
||||||
|
const win = fakes({ getLastExportDir: vi.fn(async () => "C:\\Users\\Sample\\Documents") });
|
||||||
|
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, win.deps);
|
||||||
|
expect((win.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("C:\\Users\\Sample\\Documents\\INV-2026-007_Acme Learning_2026-09-15.pdf");
|
||||||
|
|
||||||
|
const none = fakes({ getLastExportDir: vi.fn(async () => Promise.reject(new Error("no dir"))) });
|
||||||
|
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, none.deps);
|
||||||
|
expect((none.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("INV-2026-007_Acme Learning_2026-09-15.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a write failure is WRITE_FAILED; a render failure on re-render is RENDER_FAILED", async () => {
|
||||||
|
const w = fakes({ writeExportFile: vi.fn(async () => Promise.reject("permission denied")) });
|
||||||
|
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, w.deps).catch((e) => e)).toMatchObject({ code: "WRITE_FAILED" });
|
||||||
|
const r = fakes({ render: vi.fn(async () => Promise.reject(new Error("fonts"))) });
|
||||||
|
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, r.deps).catch((e) => e)).toMatchObject({ code: "RENDER_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { FlattenCancelled } from "../pdf/flatten";
|
||||||
|
import type { FlattenResult } from "../pdf/flatten";
|
||||||
|
import type { AssemblePageData } from "../pdf/render/jobs";
|
||||||
|
import { exportFileName, type ExportMode } from "./filename";
|
||||||
|
import type { Invoice, InvoiceInput } from "./types";
|
||||||
|
|
||||||
|
export type { ExportMode };
|
||||||
|
export type ExportSource = "original" | "rerender";
|
||||||
|
|
||||||
|
/** Everything the flow touches outside itself, so it can run against fakes. */
|
||||||
|
export interface ExportDeps {
|
||||||
|
issueInvoice(input: InvoiceInput, renderPrefs: object): Promise<Invoice>;
|
||||||
|
/** Renders with the invoice's frozen prefs and supplier snapshot and the current template code. */
|
||||||
|
render(invoice: Invoice): Promise<{ bytes: Uint8Array; fingerprint: string }>;
|
||||||
|
archivePdf(invoiceId: number, bytes: Uint8Array, fingerprint: string): Promise<string>;
|
||||||
|
readArchive(invoiceId: number): Promise<Uint8Array>;
|
||||||
|
/** Rasterises PDF bytes page by page. Throws FlattenError / FlattenCancelled. */
|
||||||
|
flatten(bytes: Uint8Array, opts: { signal?: AbortSignal; onProgress?: (page: number, pages: number) => void }): Promise<FlattenResult>;
|
||||||
|
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
|
||||||
|
getLastExportDir(): Promise<string>;
|
||||||
|
/** The save dialog: the chosen path, or null when it is dismissed. */
|
||||||
|
save(opts: { defaultPath: string }): Promise<string | null>;
|
||||||
|
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportPhase = "rendering" | "flattening" | "assembling" | "saving";
|
||||||
|
|
||||||
|
export interface ExportProgress {
|
||||||
|
phase: ExportPhase;
|
||||||
|
/** While flattening: the page being rendered and the page count. */
|
||||||
|
page?: number;
|
||||||
|
pages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportErrorCode = "RENDER_FAILED" | "ARCHIVE_FAILED" | "FLATTEN_FAILED" | "WRITE_FAILED";
|
||||||
|
|
||||||
|
export class ExportError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: ExportErrorCode,
|
||||||
|
message: string,
|
||||||
|
readonly cause?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ExportError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
|
||||||
|
export const isFlattenFailure = (e: unknown): boolean => (e as { code?: unknown } | null)?.code === "FLATTEN_FAILED";
|
||||||
|
export const isExportCancelled = (e: unknown): boolean => e instanceof FlattenCancelled || (e as { name?: string } | null)?.name === "FlattenCancelled";
|
||||||
|
|
||||||
|
const checkAbort = (signal?: AbortSignal) => {
|
||||||
|
if (signal?.aborted) throw new FlattenCancelled();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- archive at issue ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ArchiveOutcome {
|
||||||
|
/** The invoice with its archive fields filled in when archiving worked. */
|
||||||
|
invoice: Invoice;
|
||||||
|
archived: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields the backend sets when it archives, mirrored locally so the UI needs no refetch. */
|
||||||
|
const withArchive = (invoice: Invoice, sha256: string, fingerprint: string): Invoice => ({
|
||||||
|
...invoice,
|
||||||
|
archivedPdfSha256: sha256,
|
||||||
|
archivedFingerprint: fingerprint,
|
||||||
|
archivedAt: invoice.archivedAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an issued invoice with its frozen prefs and archives the result. Never throws: a failure
|
||||||
|
* comes back as `archived: false` with the reason, because the invoice already exists.
|
||||||
|
*/
|
||||||
|
export async function renderAndArchive(invoice: Invoice, deps: Pick<ExportDeps, "render" | "archivePdf">): Promise<ArchiveOutcome> {
|
||||||
|
let rendered: { bytes: Uint8Array; fingerprint: string };
|
||||||
|
try {
|
||||||
|
rendered = await deps.render(invoice);
|
||||||
|
} catch (e) {
|
||||||
|
return { invoice, archived: false, error: `The PDF could not be rendered: ${messageOf(e)}` };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
|
||||||
|
return { invoice: withArchive(invoice, sha, rendered.fingerprint), archived: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { invoice, archived: false, error: `The PDF could not be archived: ${messageOf(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues the invoice, then renders it with the prefs the backend froze and archives that PDF. The
|
||||||
|
* issue call's own failure throws (nothing was issued); everything after it is reported, never thrown.
|
||||||
|
*/
|
||||||
|
export async function issueAndArchive(
|
||||||
|
input: InvoiceInput,
|
||||||
|
renderPrefs: object,
|
||||||
|
deps: Pick<ExportDeps, "issueInvoice" | "render" | "archivePdf">,
|
||||||
|
): Promise<ArchiveOutcome> {
|
||||||
|
const invoice = await deps.issueInvoice(input, renderPrefs);
|
||||||
|
return renderAndArchive(invoice, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- export ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ExportRequest {
|
||||||
|
invoice: Invoice;
|
||||||
|
mode: ExportMode;
|
||||||
|
source: ExportSource;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (p: ExportProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
path: string;
|
||||||
|
mode: ExportMode;
|
||||||
|
/** Only for a re-render of an archived invoice: did the layout come out the same as at issue? */
|
||||||
|
identicalToIssued?: boolean;
|
||||||
|
/** The invoice with archive fields filled in when this export had to archive it first. */
|
||||||
|
invoice: Invoice;
|
||||||
|
/** The export used a fresh render because archiving the missing original failed. */
|
||||||
|
archiveError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(dir: string, name: string): string {
|
||||||
|
if (!dir) return name;
|
||||||
|
const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
|
||||||
|
return /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${sep}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces the PDF bytes for one export and saves them. Returns null when the save dialog is
|
||||||
|
* dismissed. Errors: FlattenCancelled when aborted; an ExportError (or a FlattenError, which carries
|
||||||
|
* the same `FLATTEN_FAILED` code) otherwise. A failed flatten is never replaced by a searchable export
|
||||||
|
* or a lower resolution; the caller offers that choice.
|
||||||
|
*/
|
||||||
|
export async function exportInvoice(req: ExportRequest, deps: ExportDeps): Promise<ExportResult | null> {
|
||||||
|
const { mode, source, signal, onProgress } = req;
|
||||||
|
let invoice = req.invoice;
|
||||||
|
checkAbort(signal);
|
||||||
|
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
let identicalToIssued: boolean | undefined;
|
||||||
|
let archiveError: string | undefined;
|
||||||
|
|
||||||
|
if (source === "rerender") {
|
||||||
|
onProgress?.({ phase: "rendering" });
|
||||||
|
const rendered = await renderOrThrow(invoice, deps);
|
||||||
|
bytes = rendered.bytes;
|
||||||
|
if (invoice.archivedPdfSha256 && invoice.archivedFingerprint) {
|
||||||
|
identicalToIssued = rendered.fingerprint === invoice.archivedFingerprint;
|
||||||
|
}
|
||||||
|
} else if (invoice.archivedPdfSha256) {
|
||||||
|
onProgress?.({ phase: "rendering" });
|
||||||
|
bytes = await deps.readArchive(invoice.id);
|
||||||
|
} else {
|
||||||
|
// Issued but never archived (the render failed at issue): do it now, then use that file.
|
||||||
|
onProgress?.({ phase: "rendering" });
|
||||||
|
const rendered = await renderOrThrow(invoice, deps);
|
||||||
|
bytes = rendered.bytes;
|
||||||
|
try {
|
||||||
|
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
|
||||||
|
invoice = withArchive(invoice, sha, rendered.fingerprint);
|
||||||
|
} catch (e) {
|
||||||
|
// The invoice is intact; export the fresh render and let the caller say the archive is still missing.
|
||||||
|
archiveError = `The PDF could not be archived: ${messageOf(e)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkAbort(signal);
|
||||||
|
|
||||||
|
if (mode === "flattened") {
|
||||||
|
try {
|
||||||
|
const flat = await deps.flatten(bytes, {
|
||||||
|
signal,
|
||||||
|
onProgress: (page, pages) => onProgress?.({ phase: "flattening", page, pages }),
|
||||||
|
});
|
||||||
|
checkAbort(signal);
|
||||||
|
onProgress?.({ phase: "assembling" });
|
||||||
|
// The title is the invoice number only; anything more would leak what the image-only file hides.
|
||||||
|
bytes = await deps.assemble(
|
||||||
|
flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
|
||||||
|
invoice.number,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (isExportCancelled(e) || isFlattenFailure(e)) throw e;
|
||||||
|
throw new ExportError("FLATTEN_FAILED", `The flattened PDF could not be built: ${messageOf(e)}`, e);
|
||||||
|
}
|
||||||
|
checkAbort(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await deps.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const name = exportFileName({ number: invoice.number, client: invoice.clientName, date: invoice.invoiceDate, mode });
|
||||||
|
onProgress?.({ phase: "saving" });
|
||||||
|
const chosen = await deps.save({ defaultPath: joinPath(dir, name) });
|
||||||
|
if (!chosen) return null;
|
||||||
|
|
||||||
|
let path: string;
|
||||||
|
try {
|
||||||
|
path = (await deps.writeExportFile(chosen, bytes)) || chosen;
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExportError("WRITE_FAILED", `The file could not be saved: ${messageOf(e)}`, e);
|
||||||
|
}
|
||||||
|
return { path, mode, invoice, ...(identicalToIssued === undefined ? {} : { identicalToIssued }), ...(archiveError ? { archiveError } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOrThrow(invoice: Invoice, deps: Pick<ExportDeps, "render">) {
|
||||||
|
try {
|
||||||
|
return await deps.render(invoice);
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExportError("RENDER_FAILED", `The PDF could not be rendered: ${messageOf(e)}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ExportMode } from "./filename";
|
||||||
|
|
||||||
|
const KEY = "voiced.exportMode";
|
||||||
|
|
||||||
|
/** The last export mode this viewer used; searchable until one is chosen. A per-viewer convenience only. */
|
||||||
|
export function loadExportMode(): ExportMode {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(KEY);
|
||||||
|
if (v === "searchable" || v === "flattened") return v;
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked or empty; the default is fine.
|
||||||
|
}
|
||||||
|
return "searchable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveExportMode(mode: ExportMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// Not remembering is harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPORT_MODE_LABEL: Record<ExportMode, string> = {
|
||||||
|
searchable: "Searchable PDF (copy-pasteable text)",
|
||||||
|
flattened: "Flattened PDF (image only)",
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { exportFileName, type ExportFileNameInput } from "./filename";
|
||||||
|
|
||||||
|
const base: ExportFileNameInput = {
|
||||||
|
number: "INV-2026-001",
|
||||||
|
client: "Acme Ltd",
|
||||||
|
date: "2026-04-01",
|
||||||
|
mode: "searchable",
|
||||||
|
};
|
||||||
|
const name = (over: Partial<ExportFileNameInput> = {}) => exportFileName({ ...base, ...over });
|
||||||
|
|
||||||
|
describe("exportFileName", () => {
|
||||||
|
it("uses number_client_date by default", () => {
|
||||||
|
expect(name()).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a slash in the invoice number into a dash", () => {
|
||||||
|
expect(name({ number: "INV/2026-001" })).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds -flat for the flattened export", () => {
|
||||||
|
expect(name({ mode: "flattened" })).toBe("INV-2026-001_Acme Ltd_2026-04-01-flat.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a custom pattern", () => {
|
||||||
|
expect(name({ pattern: "{date} {client} {number}" })).toBe("2026-04-01 Acme Ltd INV-2026-001.pdf");
|
||||||
|
expect(name({ pattern: "{number}" })).toBe("INV-2026-001.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default pattern for a blank pattern", () => {
|
||||||
|
expect(name({ pattern: " " })).toBe(name());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces every Windows-forbidden character", () => {
|
||||||
|
expect(name({ client: 'A<B>C:D"E/F\\G|H?I*J' })).toBe("INV-2026-001_A-B-C-D-E-F-G-H-I-J_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces control characters", () => {
|
||||||
|
expect(name({ client: "A\u0000B\tC\nD\u007fE" })).toBe("INV-2026-001_A-B-C-D-E_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses runs of separators and spaces", () => {
|
||||||
|
expect(name({ client: "A///B C___D" })).toBe("INV-2026-001_A-B C_D_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims dots and spaces from the ends", () => {
|
||||||
|
expect(name({ pattern: " {client}. . " })).toBe("Acme Ltd.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "Acme Ltd. " })).toBe("Acme Ltd.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "...hidden" })).toBe("hidden.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the separators left behind by empty parts", () => {
|
||||||
|
expect(name({ client: "" })).toBe("INV-2026-001_2026-04-01.pdf");
|
||||||
|
expect(name({ client: "", date: "" })).toBe("INV-2026-001.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes reserved device names", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "CON" })).toBe("_CON.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "nul" })).toBe("_nul.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "Aux" })).toBe("_Aux.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "PRN" })).toBe("_PRN.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes COM1-9 and LPT1-9 but not COM10 or lookalikes", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "COM1" })).toBe("_COM1.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "com9" })).toBe("_com9.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "LPT3" })).toBe("_LPT3.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "COM10" })).toBe("COM10.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "CONSOLE" })).toBe("CONSOLE.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a reserved name that has an extension-like tail", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "CON.txt" })).toBe("_CON.txt.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Indic client names intact", () => {
|
||||||
|
expect(name({ client: "சென்னை ஸ்டூடியோ" })).toBe(
|
||||||
|
"INV-2026-001_சென்னை ஸ்டூடியோ_2026-04-01.pdf",
|
||||||
|
);
|
||||||
|
expect(name({ client: "ਭਾਰਤ/ਕੰਪਨੀ" })).toContain("ਭਾਰਤ-ਕੰਪਨੀ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises Unicode to NFC", () => {
|
||||||
|
const decomposed = "Café";
|
||||||
|
expect(name({ pattern: "{client}", client: decomposed })).toBe("Café.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the stem at 120 characters and cuts the client first", () => {
|
||||||
|
const out = name({ client: "x".repeat(300) });
|
||||||
|
const stem = out.slice(0, -".pdf".length);
|
||||||
|
expect(stem.length).toBeLessThanOrEqual(120);
|
||||||
|
expect(out.startsWith("INV-2026-001_x")).toBe(true);
|
||||||
|
expect(out.endsWith("_2026-04-01.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the number whole even when it alone is long", () => {
|
||||||
|
const number = "N".repeat(110);
|
||||||
|
const out = name({ number, client: "y".repeat(200) });
|
||||||
|
expect(out.startsWith(number)).toBe(true);
|
||||||
|
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-caps an absurdly long number", () => {
|
||||||
|
const out = name({ number: "9".repeat(500) });
|
||||||
|
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not split a surrogate pair when truncating", () => {
|
||||||
|
const out = name({ pattern: "{client}", client: "\u{1F600}".repeat(100) });
|
||||||
|
const stem = out.slice(0, -".pdf".length);
|
||||||
|
expect(stem.length).toBeLessThanOrEqual(120);
|
||||||
|
expect([...stem].every((ch) => ch === "\u{1F600}")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds -flat after truncation", () => {
|
||||||
|
const out = name({ client: "z".repeat(300), mode: "flattened" });
|
||||||
|
expect(out.endsWith("_2026-04-01-flat.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a non-empty fallback", () => {
|
||||||
|
expect(name({ number: "", client: "", date: "" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ pattern: "...", number: "x" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ number: "///", client: "***", date: "|" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ number: "", client: "", date: "", mode: "flattened" })).toBe("invoice-flat.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the result never ends in a dot or space", () => {
|
||||||
|
const out = name({ client: "Trailing dot.", date: "" });
|
||||||
|
expect(out).not.toMatch(/[. ]\.pdf$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type ExportMode = "searchable" | "flattened";
|
||||||
|
|
||||||
|
export interface ExportFileNameInput {
|
||||||
|
/** Tokens: {number}, {client}, {date}. */
|
||||||
|
pattern?: string;
|
||||||
|
number: string;
|
||||||
|
client: string;
|
||||||
|
date: string;
|
||||||
|
mode: ExportMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PATTERN = "{number}_{client}_{date}";
|
||||||
|
const MAX_STEM = 120;
|
||||||
|
const FALLBACK = "invoice";
|
||||||
|
|
||||||
|
// Characters Windows forbids in file names, plus control characters.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
|
||||||
|
// The device name matters even with an extension ("CON.txt" is still the console).
|
||||||
|
const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i;
|
||||||
|
const EDGE_JUNK = /^[.\s_-]+|[.\s_-]+$/g;
|
||||||
|
|
||||||
|
function collapse(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(FORBIDDEN, "-")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/_{2,}/g, "_")
|
||||||
|
.replace(/ ?([-_]) ?/g, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPart(value: string): string {
|
||||||
|
return collapse(value.normalize("NFC")).replace(EDGE_JUNK, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cut to at most `max` UTF-16 units without splitting a surrogate pair. */
|
||||||
|
function truncate(value: string, max: number): string {
|
||||||
|
let out = "";
|
||||||
|
for (const ch of value) {
|
||||||
|
if (out.length + ch.length > max) break;
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(pattern: string, parts: { number: string; client: string; date: string }): string {
|
||||||
|
return pattern.replace(/\{(number|client|date)\}/g, (_, key: keyof typeof parts) => parts[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file name that is valid on Windows, macOS and Linux for an exported invoice PDF. */
|
||||||
|
export function exportFileName(input: ExportFileNameInput): string {
|
||||||
|
const pattern = input.pattern?.trim() ? input.pattern : DEFAULT_PATTERN;
|
||||||
|
const parts = {
|
||||||
|
number: cleanPart(input.number),
|
||||||
|
client: cleanPart(input.client),
|
||||||
|
date: cleanPart(input.date),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stem = fill(pattern, parts);
|
||||||
|
if (stem.length > MAX_STEM) {
|
||||||
|
// The invoice number is what identifies the file, so the client name gives way first.
|
||||||
|
const overflow = stem.length - MAX_STEM;
|
||||||
|
const client = truncate(parts.client, Math.max(0, parts.client.length - overflow));
|
||||||
|
stem = fill(pattern, { ...parts, client });
|
||||||
|
}
|
||||||
|
stem = cleanPart(truncate(cleanPart(stem), MAX_STEM));
|
||||||
|
if (!stem) stem = FALLBACK;
|
||||||
|
if (RESERVED.test(stem.split(".")[0])) stem = `_${stem}`;
|
||||||
|
|
||||||
|
return `${stem}${input.mode === "flattened" ? "-flat" : ""}.pdf`;
|
||||||
|
}
|
||||||
+60
-96
@@ -1,108 +1,72 @@
|
|||||||
import { pdf } from "@react-pdf/renderer";
|
import pkg from "../../package.json";
|
||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
import { registerPdfFonts } from "../pdf/fonts";
|
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
|
||||||
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
|
import { vendorFromSnapshot } from "../pdf/model/vendor";
|
||||||
|
import { Superseded } from "../pdf/render/client";
|
||||||
|
import type { RenderResult } from "../pdf/render/jobs";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { imageSizeFromDataUri } from "./imageSize";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
import type { BankAccount, Invoice, Settings } from "./types";
|
import type { BankAccount, Invoice, Settings } from "./types";
|
||||||
|
|
||||||
function blobToBase64(blob: Blob): Promise<string> {
|
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
|
||||||
return new Promise((resolve, reject) => {
|
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const result = String(reader.result ?? "");
|
|
||||||
resolve(result.substring(result.indexOf(",") + 1));
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
|
/**
|
||||||
if (!snapshot) return live;
|
* The render model for an invoice whose images are already loaded (the live preview caches them).
|
||||||
try {
|
* Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback.
|
||||||
return { ...live, ...(JSON.parse(snapshot) as Partial<Settings>) };
|
*/
|
||||||
} catch {
|
export function modelFromAssets(
|
||||||
return live;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildPdfProps(
|
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
bank: BankAccount | null,
|
bank: BankAccount | null,
|
||||||
): Promise<InvoicePdfProps> {
|
logoUri: string | null,
|
||||||
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
|
signatureDataUri: string | null,
|
||||||
// legacy rows without a snapshot.
|
): RenderModel {
|
||||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
return buildRenderModel({
|
||||||
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
invoice,
|
||||||
const signatureDataUri = invoice.signaturePath
|
vendor: settings,
|
||||||
? await api.readAssetDataUri(invoice.signaturePath)
|
bank,
|
||||||
: null;
|
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
|
||||||
|
signatureDataUri,
|
||||||
|
appVersion: pkg.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let resolvedBank = bank;
|
/** Loads the invoice's logo and signature, then builds its render model. */
|
||||||
if (!resolvedBank && invoice.bankSnapshot) {
|
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
|
||||||
|
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
||||||
|
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
||||||
|
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
|
||||||
|
return modelFromAssets(invoice, settings, bank, logoUri, signatureDataUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page setup for an invoice: what was frozen at issue, else the user's current default. */
|
||||||
|
export const prefsForInvoice = (invoice: Invoice, settings: Settings): RenderPrefsV1 =>
|
||||||
|
parsePrefs(invoice.renderPrefs || settings.renderPrefs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an issued invoice to PDF bytes through the shared render client (worker, or the main
|
||||||
|
* thread if the worker cannot start), with the layout audit issues and fingerprint.
|
||||||
|
*/
|
||||||
|
export async function renderInvoicePdf(
|
||||||
|
invoice: Invoice,
|
||||||
|
settings: Settings,
|
||||||
|
bank: BankAccount | null,
|
||||||
|
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
|
||||||
|
): Promise<RenderResult> {
|
||||||
|
const model = await buildModelForInvoice(invoice, settings, bank);
|
||||||
|
return renderModelWithRetry(model, prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a ready model in the shared client. */
|
||||||
|
export async function renderModelWithRetry(model: RenderModel, prefs: RenderPrefsV1): Promise<RenderResult> {
|
||||||
|
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
|
return await getRenderClient().render({ id: nextRenderJobId(), model, prefs });
|
||||||
} catch {
|
} catch (err) {
|
||||||
resolvedBank = null;
|
if (!(err instanceof Superseded) || attempt >= 4) throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
vendor,
|
|
||||||
bank: resolvedBank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri,
|
|
||||||
number: invoice.number,
|
|
||||||
invoiceDate: invoice.invoiceDate,
|
|
||||||
dueDate: invoice.dueDate,
|
|
||||||
clientName: invoice.clientName,
|
|
||||||
clientAddress: invoice.clientAddress,
|
|
||||||
clientGstin: invoice.clientGstin,
|
|
||||||
poNumber: invoice.poNumber,
|
|
||||||
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
|
|
||||||
items: invoice.items,
|
|
||||||
subtotal: invoice.subtotal,
|
|
||||||
discount: invoice.discount,
|
|
||||||
taxType: invoice.taxType,
|
|
||||||
taxRate: invoice.taxRate,
|
|
||||||
cgst: invoice.cgstAmount,
|
|
||||||
sgst: invoice.sgstAmount,
|
|
||||||
igst: invoice.igstAmount,
|
|
||||||
total: invoice.total,
|
|
||||||
amountInWords: invoice.amountInWords,
|
|
||||||
notes: invoice.notes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render props straight to a PDF blob. */
|
|
||||||
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> {
|
|
||||||
registerPdfFonts();
|
|
||||||
return pdf(<InvoiceDocument {...props} />).toBlob();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render a PDF and prompt the user for a save location. */
|
|
||||||
export async function exportPdfFromProps(
|
|
||||||
props: InvoicePdfProps,
|
|
||||||
fileName: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const blob = await renderPdfBlob(props);
|
|
||||||
const dataBase64 = await blobToBase64(blob);
|
|
||||||
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
|
|
||||||
const path = await save({
|
|
||||||
defaultPath: `${suggested}.pdf`,
|
|
||||||
filters: [{ name: "PDF document", extensions: ["pdf"] }],
|
|
||||||
});
|
|
||||||
if (!path) return null;
|
|
||||||
await api.saveBinaryFile(path, dataBase64);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportInvoicePdf(
|
|
||||||
invoice: Invoice,
|
|
||||||
settings: Settings,
|
|
||||||
bank: BankAccount | null,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const props = await buildPdfProps(invoice, settings, bank);
|
|
||||||
return exportPdfFromProps(props, invoice.number);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { getDocument, GlobalWorkerOptions, type PDFDocumentProxy, type PDFPageProxy, type RenderTask } from "pdfjs-dist";
|
||||||
|
import { installStreamAsyncIterator } from "./streamPolyfill";
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||||
|
installStreamAsyncIterator();
|
||||||
|
|
||||||
|
export type { PDFDocumentProxy, PDFPageProxy };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens PDF bytes. The bytes are copied because pdf.js transfers the buffer to its worker, which
|
||||||
|
* would detach the caller's array. Glyphs are drawn as paths (`disableFontFace`) so the preview looks
|
||||||
|
* the same on WebKitGTK and WebView2. (pdf.js 6 dropped `isEvalSupported`; it no longer evaluates font code.)
|
||||||
|
*/
|
||||||
|
export function openPdf(bytes: Uint8Array): Promise<PDFDocumentProxy> {
|
||||||
|
return getDocument({ data: bytes.slice(), disableFontFace: true }).promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the document and its worker resources. */
|
||||||
|
export function closePdf(doc: PDFDocumentProxy): Promise<void> {
|
||||||
|
return doc.loadingTask.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = new WeakMap<object, RenderTask>();
|
||||||
|
|
||||||
|
/** Cancels whatever render is running for `slot` (a canvas, or any object the caller keys on). */
|
||||||
|
export function cancelPageRender(slot: object): void {
|
||||||
|
tasks.get(slot)?.cancel();
|
||||||
|
tasks.delete(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws `page` into `canvas` at `scale` device pixels per PDF point, sizing the backing store to
|
||||||
|
* match. Starting a render for a slot cancels its previous one; a cancelled render rejects with
|
||||||
|
* pdf.js's RenderingCancelledException (see isRenderCancelled).
|
||||||
|
*/
|
||||||
|
export async function renderPageToCanvas(page: PDFPageProxy, canvas: HTMLCanvasElement, scale: number, slot: object = canvas): Promise<void> {
|
||||||
|
cancelPageRender(slot);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const task = page.render({ canvas, viewport });
|
||||||
|
tasks.set(slot, task);
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} finally {
|
||||||
|
if (tasks.get(slot) === task) tasks.delete(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRenderCancelled = (err: unknown): boolean => (err as { name?: string } | null)?.name === "RenderingCancelledException";
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "./types";
|
||||||
|
import { computeLineAmount, computeTotals } from "./invoice";
|
||||||
|
import { amountInWords } from "./numberToWords";
|
||||||
|
|
||||||
|
/** The form fields the preview reads. */
|
||||||
|
export interface PreviewForm {
|
||||||
|
invoiceDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string;
|
||||||
|
clientAddress: string;
|
||||||
|
clientGstin: string;
|
||||||
|
poNumber: string;
|
||||||
|
placeOfSupply: string;
|
||||||
|
discount: number;
|
||||||
|
taxRate: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
signaturePath: string | null;
|
||||||
|
notes: string;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tax treatment Rust derives from the supplier state and the place of supply
|
||||||
|
* (src-tauri/src/gst.rs); the form shows and sends the same value.
|
||||||
|
*/
|
||||||
|
export function deriveTaxType(settings: Settings, placeOfSupply: string): TaxType {
|
||||||
|
if (settings.gstRegistration === "unregistered") return "none";
|
||||||
|
return !placeOfSupply || placeOfSupply === settings.vendorStateCode ? "cgst_sgst" : "igst";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors vendor_snapshot() in src-tauri/src/commands/invoice.rs. */
|
||||||
|
export function vendorSnapshotJson(settings: Settings): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
vendorName: settings.vendorName,
|
||||||
|
vendorAddress: settings.vendorAddress,
|
||||||
|
vendorEmail: settings.vendorEmail,
|
||||||
|
vendorPhone: settings.vendorPhone,
|
||||||
|
vendorPan: settings.vendorPan,
|
||||||
|
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
|
||||||
|
vendorStateCode: settings.vendorStateCode,
|
||||||
|
logoPath: settings.logoPath,
|
||||||
|
signaturePath: settings.signaturePath,
|
||||||
|
gstRegistration: settings.gstRegistration,
|
||||||
|
signatoryName: settings.signatoryName,
|
||||||
|
signatoryDesignation: settings.signatoryDesignation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft-status Invoice built from the live form, so the real renderer can draw it before anything
|
||||||
|
* is issued. Totals use the same paise rounding Rust stores. `number` is the next number (a peek),
|
||||||
|
* not yet consumed.
|
||||||
|
*/
|
||||||
|
export function formToPreviewInvoice(form: PreviewForm, number: string, settings: Settings, bank: BankAccount | null): Invoice {
|
||||||
|
const taxType = deriveTaxType(settings, form.placeOfSupply);
|
||||||
|
const registered = settings.gstRegistration !== "unregistered";
|
||||||
|
const totals = computeTotals(form.items, form.discount, taxType, form.taxRate);
|
||||||
|
const items = form.items.map((item, i) => ({ ...item, amount: computeLineAmount(item), sortOrder: i }));
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
number,
|
||||||
|
seriesId: null,
|
||||||
|
invoiceDate: form.invoiceDate,
|
||||||
|
dueDate: form.dueDate,
|
||||||
|
clientId: form.clientId,
|
||||||
|
clientName: form.clientName,
|
||||||
|
clientAddress: form.clientAddress,
|
||||||
|
clientGstin: form.clientGstin,
|
||||||
|
poNumber: form.poNumber,
|
||||||
|
placeOfSupplyStateCode: form.placeOfSupply,
|
||||||
|
subtotal: totals.subtotal,
|
||||||
|
discount: totals.discount,
|
||||||
|
taxType,
|
||||||
|
taxRate: form.taxRate,
|
||||||
|
cgstAmount: totals.cgst,
|
||||||
|
sgstAmount: totals.sgst,
|
||||||
|
igstAmount: totals.igst,
|
||||||
|
total: totals.total,
|
||||||
|
amountInWords: amountInWords(totals.total),
|
||||||
|
bankAccountId: bank?.id ?? null,
|
||||||
|
bankSnapshot: bank ? JSON.stringify(bank) : "",
|
||||||
|
signaturePath: form.signaturePath,
|
||||||
|
notes: form.notes,
|
||||||
|
status: "draft",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
docType: registered ? "tax_invoice" : "invoice",
|
||||||
|
reverseCharge: registered && form.reverseCharge,
|
||||||
|
vendorSnapshot: vendorSnapshotJson(settings),
|
||||||
|
snapshotOrigin: "issued",
|
||||||
|
cancelledAt: null,
|
||||||
|
cancelReason: "",
|
||||||
|
archivedPdfSha256: null,
|
||||||
|
renderPrefs: "",
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createAutoClient, type RenderClient } from "../pdf/render/client";
|
||||||
|
|
||||||
|
let client: RenderClient | null = null;
|
||||||
|
let lastId = 0;
|
||||||
|
|
||||||
|
/** The one render client (and so one worker) shared by the preview and export. */
|
||||||
|
export function getRenderClient(): RenderClient {
|
||||||
|
client ??= createAutoClient();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nextRenderJobId = (): number => ++lastId;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { CanvasLike } from "../pdf/flatten";
|
||||||
|
import { runAssembleJob } from "../pdf/render/assembleJob";
|
||||||
|
import { renderInvoicePdf } from "../pdf/render/invoice";
|
||||||
|
import { ensureSetup } from "../pdf/templates/harness";
|
||||||
|
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||||
|
import goldens from "../pdf/testing/goldens.json";
|
||||||
|
import { loadCanvas } from "../pdf/testing/rasterize";
|
||||||
|
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
|
||||||
|
import { reportToText, runSelfTest, type SelfTestDeps, type SelfTestDoc } from "./selfTest";
|
||||||
|
|
||||||
|
const canvasLib = await loadCanvas();
|
||||||
|
if (!canvasLib) console.warn("[selfTest.test] @napi-rs/canvas is not installed: skipping the self-test run under Node");
|
||||||
|
|
||||||
|
function nodeDeps(over: Partial<SelfTestDeps> = {}): SelfTestDeps {
|
||||||
|
ensureSetup();
|
||||||
|
return {
|
||||||
|
env: () => ({ userAgent: "node", isSecureContext: true, renderMode: "worker" }),
|
||||||
|
fixtures: selfTestFixtures(),
|
||||||
|
goldens: goldens as GoldenMap,
|
||||||
|
render: async (f) => {
|
||||||
|
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
|
||||||
|
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: 1 };
|
||||||
|
},
|
||||||
|
openPdf: async (bytes) => {
|
||||||
|
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
|
||||||
|
const doc = await task.promise;
|
||||||
|
return Object.assign(doc, { close: () => task.destroy() }) as unknown as SelfTestDoc;
|
||||||
|
},
|
||||||
|
createCanvas: (w, h) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike,
|
||||||
|
encodeJpeg: async (c, q) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise<Buffer> }).encode("jpeg", Math.round(q * 100))),
|
||||||
|
assemble: async (pages, title) => (await runAssembleJob({ id: 1, pages, title })).bytes,
|
||||||
|
startFrameMonitor: () => () => 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!canvasLib)("runSelfTest under Node", () => {
|
||||||
|
it("passes every check against the committed goldens", async () => {
|
||||||
|
const updates: number[] = [];
|
||||||
|
const report = await runSelfTest(nodeDeps(), (r) => updates.push(r.sections.length));
|
||||||
|
const failed = report.sections.flatMap((s) => s.checks.filter((c) => c.status === "fail").map((c) => `${s.title}: ${c.label}: ${c.detail}`));
|
||||||
|
expect(failed).toEqual([]);
|
||||||
|
expect(report.passed && report.done).toBe(true);
|
||||||
|
expect(updates.length).toBeGreaterThan(10);
|
||||||
|
const labels = report.sections.flatMap((s) => s.checks.map((c) => c.label));
|
||||||
|
for (const l of ["Page count", "Layout audit", "Rasterise page 1", "Text extraction", "Layout fingerprint matches the Node golden", "Longest main-thread stall", "Assemble the image-only PDF"]) {
|
||||||
|
expect(labels).toContain(l);
|
||||||
|
}
|
||||||
|
expect(reportToText(report)).toContain("RESULT: all checks passed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a fingerprint difference clearly, with both values", async () => {
|
||||||
|
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||||
|
const id = Object.keys(tampered)[0];
|
||||||
|
tampered[id].fingerprint = "0".repeat(64);
|
||||||
|
const report = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||||
|
const check = report.sections.flatMap((s) => s.checks).find((c) => c.label.startsWith("Layout fingerprint"))!;
|
||||||
|
expect(check.status).toBe("fail");
|
||||||
|
expect(check.detail).toContain("0".repeat(64));
|
||||||
|
expect(check.detail).toContain("this webview");
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
expect(reportToText(report)).toContain("RESULT: FAILED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the text check when the page does not contain the expected strings, and a render error stops that fixture", async () => {
|
||||||
|
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||||
|
const id = Object.keys(tampered)[0];
|
||||||
|
tampered[id].totalText = "₹9,999,999.99";
|
||||||
|
const a = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||||
|
expect(a.sections.flatMap((s) => s.checks).find((c) => c.label === "Text extraction")).toMatchObject({ status: "fail" });
|
||||||
|
|
||||||
|
const b = await runSelfTest(nodeDeps({ render: async () => Promise.reject(new Error("worker could not start")), fixtures: selfTestFixtures().slice(0, 1) }));
|
||||||
|
const sec = b.sections.find((s) => s.title.startsWith("Classic, A4"))!;
|
||||||
|
expect(sec.checks.map((c) => c.status)).toEqual(["fail"]);
|
||||||
|
expect(sec.checks[0].detail).toContain("worker could not start");
|
||||||
|
expect(b.sections.some((s) => s.title.startsWith("300 DPI"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the stall check when the longest frame gap exceeds the budget", async () => {
|
||||||
|
const report = await runSelfTest(nodeDeps({ startFrameMonitor: () => () => 900 }));
|
||||||
|
const stall = report.sections.flatMap((s) => s.checks).find((c) => c.label === "Longest main-thread stall")!;
|
||||||
|
expect(stall.status).toBe("fail");
|
||||||
|
expect(stall.detail).toContain("900 ms");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { flattenPages, type CanvasLike, type FlattenResult, type PageLike } from "../pdf/flatten";
|
||||||
|
import type { AssemblePageData } from "../pdf/render/jobs";
|
||||||
|
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||||
|
import type { SelfTestFixture } from "../pdf/testing/selfTestFixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Diagnostics self-test: renders fixtures through the app's real render client, rasterises and
|
||||||
|
* reads them back with pdf.js, compares with the Node goldens, and times a 300 DPI flatten. Everything
|
||||||
|
* environment-specific is injected, so the same checks run in the webview and under Node.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CheckStatus = "pass" | "fail" | "info";
|
||||||
|
|
||||||
|
export interface Check {
|
||||||
|
label: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
detail?: string;
|
||||||
|
ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
title: string;
|
||||||
|
checks: Check[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestReport {
|
||||||
|
startedAt: string;
|
||||||
|
sections: Section[];
|
||||||
|
done: boolean;
|
||||||
|
/** No check failed (informational rows never fail). */
|
||||||
|
passed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestPage extends PageLike {
|
||||||
|
getTextContent(): Promise<{ items: Array<{ str?: string }> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestDoc {
|
||||||
|
numPages: number;
|
||||||
|
getPage(n: number): Promise<SelfTestPage>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestRender {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
pages: number;
|
||||||
|
issues: Array<{ severity: string; message?: string }>;
|
||||||
|
fingerprint: string;
|
||||||
|
ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestDeps {
|
||||||
|
env(): { userAgent: string; isSecureContext: boolean; renderMode: string };
|
||||||
|
fixtures: SelfTestFixture[];
|
||||||
|
goldens: GoldenMap;
|
||||||
|
/** Renders in the app's real client (the worker in the app). */
|
||||||
|
render(fixture: SelfTestFixture): Promise<SelfTestRender>;
|
||||||
|
openPdf(bytes: Uint8Array): Promise<SelfTestDoc>;
|
||||||
|
createCanvas(width: number, height: number): CanvasLike;
|
||||||
|
encodeJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array>;
|
||||||
|
/** Wraps JPEG pages into an image-only PDF through the real client. */
|
||||||
|
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
|
||||||
|
/** Starts watching frame gaps; the returned function stops it and gives the longest gap in ms. */
|
||||||
|
startFrameMonitor(): () => number;
|
||||||
|
yieldToUi?: () => Promise<void>;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest allowed main-thread stall while flattening (the plan's budget). */
|
||||||
|
export const MAX_FLATTEN_STALL_MS = 500;
|
||||||
|
/** A rendered invoice page is never blank: at least this share of pixels must carry ink. */
|
||||||
|
export const MIN_INK_RATIO = 0.003;
|
||||||
|
|
||||||
|
const squash = (s: string) => s.replace(/\s+/g, "");
|
||||||
|
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
const short = (fp: string) => (fp.length > 16 ? `${fp.slice(0, 8)}…${fp.slice(-6)}` : fp);
|
||||||
|
const mb = (n: number) => `${(n / 1048576).toFixed(2)} MB`;
|
||||||
|
|
||||||
|
/** Share of non-white pixels of page 1 drawn at 1 px per point. */
|
||||||
|
async function inkRatioOfFirstPage(doc: SelfTestDoc, deps: SelfTestDeps): Promise<number> {
|
||||||
|
const page = await doc.getPage(1);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const w = Math.ceil(viewport.width);
|
||||||
|
const h = Math.ceil(viewport.height);
|
||||||
|
const canvas = deps.createCanvas(w, h);
|
||||||
|
try {
|
||||||
|
const ctx = canvas.getContext("2d") as {
|
||||||
|
fillStyle: unknown;
|
||||||
|
fillRect(x: number, y: number, w: number, h: number): void;
|
||||||
|
getImageData(x: number, y: number, w: number, h: number): { data: ArrayLike<number> };
|
||||||
|
};
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
await page.render({ canvasContext: ctx, canvas, viewport, intent: "print" }).promise;
|
||||||
|
const data = ctx.getImageData(0, 0, w, h).data;
|
||||||
|
let ink = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++;
|
||||||
|
return ink / (w * h);
|
||||||
|
} finally {
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageTexts(doc: SelfTestDoc): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let n = 1; n <= doc.numPages; n++) {
|
||||||
|
const content = await (await doc.getPage(n)).getTextContent();
|
||||||
|
out.push(content.items.map((i) => i.str ?? "").join(" "));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSelfTest(deps: SelfTestDeps, onUpdate?: (report: SelfTestReport) => void): Promise<SelfTestReport> {
|
||||||
|
const now = deps.now ?? (() => performance.now());
|
||||||
|
const report: SelfTestReport = { startedAt: new Date().toISOString(), sections: [], done: false, passed: true };
|
||||||
|
const emit = () => {
|
||||||
|
report.passed = report.sections.every((s) => s.checks.every((c) => c.status !== "fail"));
|
||||||
|
onUpdate?.({ ...report, sections: report.sections.map((s) => ({ ...s, checks: [...s.checks] })) });
|
||||||
|
};
|
||||||
|
const section = (title: string): Section => {
|
||||||
|
const s: Section = { title, checks: [] };
|
||||||
|
report.sections.push(s);
|
||||||
|
emit();
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const add = (s: Section, check: Check) => {
|
||||||
|
s.checks.push(check);
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
/** Runs a check body that returns a Check, turning a throw into a failure. */
|
||||||
|
const timed = async (s: Section, label: string, body: () => Promise<Omit<Check, "label">>) => {
|
||||||
|
const t0 = now();
|
||||||
|
try {
|
||||||
|
const r = await body();
|
||||||
|
add(s, { label, ms: Math.round(now() - t0), ...r });
|
||||||
|
} catch (e) {
|
||||||
|
add(s, { label, status: "fail", detail: errText(e), ms: Math.round(now() - t0) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = section("Webview");
|
||||||
|
const info = deps.env();
|
||||||
|
add(env, { label: "User agent", status: "info", detail: info.userAgent });
|
||||||
|
add(env, { label: "Secure context", status: "info", detail: String(info.isSecureContext) });
|
||||||
|
|
||||||
|
const rendered = new Map<string, SelfTestRender>();
|
||||||
|
for (const fixture of deps.fixtures) {
|
||||||
|
const sec = section(fixture.label);
|
||||||
|
const golden = deps.goldens[fixture.id];
|
||||||
|
let r: SelfTestRender | null = null;
|
||||||
|
await timed(sec, "Render in the app's client", async () => {
|
||||||
|
r = await deps.render(fixture);
|
||||||
|
rendered.set(fixture.id, r);
|
||||||
|
return { status: "pass", detail: `${r.pages} page${r.pages === 1 ? "" : "s"}, ${(r.bytes.byteLength / 1024).toFixed(0)} KB, ${r.ms} ms in the render client` };
|
||||||
|
});
|
||||||
|
if (!r) continue;
|
||||||
|
const res: SelfTestRender = r;
|
||||||
|
|
||||||
|
if (!golden) {
|
||||||
|
add(sec, { label: "Golden", status: "fail", detail: `goldens.json has no entry for ${fixture.id}; regenerate it with UPDATE_GOLDENS=1` });
|
||||||
|
} else {
|
||||||
|
add(sec, {
|
||||||
|
label: "Page count",
|
||||||
|
status: res.pages === golden.pages ? "pass" : "fail",
|
||||||
|
detail: res.pages === golden.pages ? `${res.pages}` : `expected ${golden.pages}, got ${res.pages}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = res.issues.filter((i) => i.severity === "error");
|
||||||
|
add(sec, {
|
||||||
|
label: "Layout audit",
|
||||||
|
status: errors.length === 0 ? "pass" : "fail",
|
||||||
|
detail: errors.length === 0 ? `no errors, ${res.issues.length} warning${res.issues.length === 1 ? "" : "s"}` : errors.slice(0, 3).map((e) => e.message ?? "error").join("; "),
|
||||||
|
});
|
||||||
|
|
||||||
|
let doc: SelfTestDoc | null = null;
|
||||||
|
try {
|
||||||
|
doc = await deps.openPdf(res.bytes);
|
||||||
|
} catch (e) {
|
||||||
|
add(sec, { label: "Open with pdf.js", status: "fail", detail: errText(e) });
|
||||||
|
}
|
||||||
|
if (doc) {
|
||||||
|
const opened = doc;
|
||||||
|
await timed(sec, "Rasterise page 1", async () => {
|
||||||
|
const ratio = await inkRatioOfFirstPage(opened, deps);
|
||||||
|
return { status: ratio >= MIN_INK_RATIO ? "pass" : "fail", detail: `${(ratio * 100).toFixed(1)}% of pixels carry ink${ratio >= MIN_INK_RATIO ? "" : " (blank page?)"}` };
|
||||||
|
});
|
||||||
|
await timed(sec, "Text extraction", async () => {
|
||||||
|
const texts = await pageTexts(opened);
|
||||||
|
const flat = squash(texts.join(" "));
|
||||||
|
const wanted = golden
|
||||||
|
? [fixture.invoiceNumber, golden.totalText, golden.pageLabel]
|
||||||
|
: [fixture.invoiceNumber, fixture.totalText, `Page 1 of ${res.pages}`];
|
||||||
|
const missing = wanted.filter((w) => !flat.includes(squash(w)));
|
||||||
|
return { status: missing.length === 0 ? "pass" : "fail", detail: missing.length === 0 ? `found ${wanted.join(", ")}` : `missing ${missing.map((m) => `"${m}"`).join(", ")}` };
|
||||||
|
});
|
||||||
|
await opened.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (golden) {
|
||||||
|
const same = res.fingerprint === golden.fingerprint;
|
||||||
|
add(sec, {
|
||||||
|
label: "Layout fingerprint matches the Node golden",
|
||||||
|
status: same ? "pass" : "fail",
|
||||||
|
detail: same
|
||||||
|
? short(res.fingerprint)
|
||||||
|
: `golden ${golden.fingerprint}\nthis webview ${res.fingerprint}\nThe layout differs from Node: text measuring or the JavaScript engine does not agree with the goldens.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten timing on the longest rendered fixture.
|
||||||
|
const multi = [...rendered.entries()].sort((a, b) => b[1].pages - a[1].pages)[0];
|
||||||
|
if (multi) {
|
||||||
|
const [id, src] = multi;
|
||||||
|
const fixture = deps.fixtures.find((f) => f.id === id)!;
|
||||||
|
const sec = section(`300 DPI flatten (${fixture.label})`);
|
||||||
|
const held: { flat: FlattenResult | null } = { flat: null };
|
||||||
|
const stop = deps.startFrameMonitor();
|
||||||
|
await timed(sec, `Flatten ${src.pages} page${src.pages === 1 ? "" : "s"}`, async () => {
|
||||||
|
const doc = await deps.openPdf(src.bytes);
|
||||||
|
let flat: FlattenResult;
|
||||||
|
try {
|
||||||
|
flat = await flattenPages({
|
||||||
|
pdf: doc as unknown as Parameters<typeof flattenPages>[0]["pdf"],
|
||||||
|
createCanvas: deps.createCanvas,
|
||||||
|
encodeJpeg: deps.encodeJpeg,
|
||||||
|
yieldToUi: deps.yieldToUi,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await doc.close().catch(() => {});
|
||||||
|
}
|
||||||
|
held.flat = flat;
|
||||||
|
const total = flat.pages.reduce((n, p) => n + p.jpeg.byteLength, 0);
|
||||||
|
return {
|
||||||
|
status: "pass",
|
||||||
|
detail: flat.pages.map((p, i) => `page ${i + 1}: render ${p.renderMs} ms + encode ${p.encodeMs} ms, ${p.pxWidth}x${p.pxHeight}, ${mb(p.jpeg.byteLength)}`).join("\n") + `\ntotal ${mb(total)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const longest = Math.round(stop());
|
||||||
|
const done = held.flat;
|
||||||
|
add(sec, {
|
||||||
|
label: "Longest main-thread stall",
|
||||||
|
status: !done ? "info" : longest <= MAX_FLATTEN_STALL_MS ? "pass" : "fail",
|
||||||
|
detail: !done ? "not measured" : `${longest} ms (budget ${MAX_FLATTEN_STALL_MS} ms)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
await timed(sec, "Assemble the image-only PDF", async () => {
|
||||||
|
const out = await deps.assemble(
|
||||||
|
done.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
|
||||||
|
fixture.invoiceNumber,
|
||||||
|
);
|
||||||
|
const doc = await deps.openPdf(out);
|
||||||
|
try {
|
||||||
|
const texts = await pageTexts(doc);
|
||||||
|
const textItems = texts.filter((t) => t.trim() !== "").length;
|
||||||
|
const ok = doc.numPages === src.pages && textItems === 0;
|
||||||
|
return { status: ok ? "pass" : "fail", detail: `${doc.numPages} page${doc.numPages === 1 ? "" : "s"}, ${textItems === 0 ? "no extractable text" : `${textItems} page(s) still have text`}, ${mb(out.byteLength)}` };
|
||||||
|
} finally {
|
||||||
|
await doc.close().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known only now: the client falls back to the main thread on its first failed start.
|
||||||
|
const renderMode = deps.env().renderMode;
|
||||||
|
add(env, { label: "Render client", status: "info", detail: renderMode === "worker" ? "worker" : "main thread (the worker could not start)" });
|
||||||
|
|
||||||
|
report.done = true;
|
||||||
|
emit();
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportToText(report: SelfTestReport): string {
|
||||||
|
const lines = [`Voiced diagnostics ${report.startedAt}`, report.done ? (report.passed ? "RESULT: all checks passed" : "RESULT: FAILED") : "RESULT: running", ""];
|
||||||
|
for (const s of report.sections) {
|
||||||
|
lines.push(`== ${s.title}`);
|
||||||
|
for (const c of s.checks) {
|
||||||
|
lines.push(`[${c.status.toUpperCase()}] ${c.label}${c.ms !== undefined ? ` (${c.ms} ms)` : ""}`);
|
||||||
|
if (c.detail) for (const l of c.detail.split("\n")) lines.push(` ${l}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import goldens from "../pdf/testing/goldens.json";
|
||||||
|
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||||
|
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
|
||||||
|
import type { CanvasLike } from "../pdf/flatten";
|
||||||
|
import { encodeCanvasJpeg, yieldToBrowser } from "./browserFlatten";
|
||||||
|
import { renderModelWithRetry } from "./pdf";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
|
import type { SelfTestDeps, SelfTestDoc } from "./selfTest";
|
||||||
|
|
||||||
|
/** The self-test wired to this webview: the real render client, pdf.js and DOM canvases. */
|
||||||
|
export function browserSelfTestDeps(): SelfTestDeps {
|
||||||
|
return {
|
||||||
|
env: () => ({
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
isSecureContext: window.isSecureContext,
|
||||||
|
renderMode: getRenderClient().mode,
|
||||||
|
}),
|
||||||
|
fixtures: selfTestFixtures(),
|
||||||
|
goldens: goldens as GoldenMap,
|
||||||
|
render: async (fixture) => {
|
||||||
|
const r = await renderModelWithRetry(fixture.model, fixture.prefs);
|
||||||
|
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: r.ms };
|
||||||
|
},
|
||||||
|
openPdf: async (bytes) => {
|
||||||
|
const { openPdf, closePdf } = await import("./pdfjs");
|
||||||
|
const doc = await openPdf(bytes);
|
||||||
|
return Object.assign(doc, { close: () => closePdf(doc) }) as unknown as SelfTestDoc;
|
||||||
|
},
|
||||||
|
createCanvas: (width, height) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
return canvas as unknown as CanvasLike;
|
||||||
|
},
|
||||||
|
encodeJpeg: encodeCanvasJpeg,
|
||||||
|
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
|
||||||
|
startFrameMonitor: () => {
|
||||||
|
let last = performance.now();
|
||||||
|
let longest = 0;
|
||||||
|
let running = true;
|
||||||
|
const tick = () => {
|
||||||
|
if (!running) return;
|
||||||
|
const t = performance.now();
|
||||||
|
longest = Math.max(longest, t - last);
|
||||||
|
last = t;
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
running = false;
|
||||||
|
// A frame that never arrived because the thread was still busy counts too.
|
||||||
|
return Math.max(longest, performance.now() - last);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
yieldToUi: yieldToBrowser,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { api } from "./api";
|
||||||
|
import { createExportDeps } from "./exportDeps";
|
||||||
|
import { exportInvoice, issueAndArchive } from "./exportFlow";
|
||||||
|
import { parsePrefs } from "../pdf/model/prefs";
|
||||||
|
import { bytesEqual, dirOf, type StepLog } from "./selfTestSteps";
|
||||||
|
import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
|
||||||
|
* launches with (it seeds the unregistered vendor, a default bank and an active series).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const today = () => {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hex = (bytes: Uint8Array) => Array.from(bytes.slice(0, 5), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
|
||||||
|
export async function runE2eSteps(log: StepLog, reportPath: string): Promise<void> {
|
||||||
|
const outDir = dirOf(reportPath);
|
||||||
|
const searchablePath = `${outDir}/e2e-searchable.pdf`;
|
||||||
|
const flattenedPath = `${outDir}/e2e-flattened.pdf`;
|
||||||
|
|
||||||
|
let s!: Settings;
|
||||||
|
let bank!: BankAccount;
|
||||||
|
let invoice: Invoice | null = null;
|
||||||
|
|
||||||
|
const loaded = await log.run("e2e: get settings and default bank", async () => {
|
||||||
|
s = await api.getSettings();
|
||||||
|
const banks = await api.listBanks();
|
||||||
|
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
|
||||||
|
if (!found) throw new Error("the fresh database has no bank account");
|
||||||
|
bank = found;
|
||||||
|
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
|
||||||
|
});
|
||||||
|
if (!loaded.ok) return;
|
||||||
|
|
||||||
|
const prefs = parsePrefs(s.renderPrefs);
|
||||||
|
const input: InvoiceInput = {
|
||||||
|
invoiceDate: today(),
|
||||||
|
dueDate: "",
|
||||||
|
clientId: null,
|
||||||
|
clientName: "Self-test Client Pvt Ltd",
|
||||||
|
clientAddress: "42 Example Road, Indiranagar, Bengaluru - 560038",
|
||||||
|
clientGstin: "",
|
||||||
|
poNumber: "PO-SELFTEST",
|
||||||
|
placeOfSupplyStateCode: "",
|
||||||
|
discount: 0,
|
||||||
|
taxType: s.gstRegistration === "unregistered" ? "none" : s.defaultTaxType,
|
||||||
|
taxRate: s.gstRegistration === "unregistered" ? 0 : s.defaultTaxRate,
|
||||||
|
reverseCharge: false,
|
||||||
|
bankAccountId: bank.id,
|
||||||
|
signaturePath: null,
|
||||||
|
notes: "Self-test invoice",
|
||||||
|
saveClient: false,
|
||||||
|
draftId: null,
|
||||||
|
items: [
|
||||||
|
{ description: "Voice-over, 60 second spot", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 4500, sortOrder: 0 },
|
||||||
|
{ description: "Studio session", mode: "rate", rate: 1200, unit: "hour", quantity: 2, amount: 0, sortOrder: 1 },
|
||||||
|
{ description: "Revision round", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 850.5, sortOrder: 2 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createExportDeps({ settings: s, bank });
|
||||||
|
// What the export wrote, per path, so the checks need no way to read arbitrary files back.
|
||||||
|
const written = new Map<string, Uint8Array>();
|
||||||
|
const exportWith = (mode: "searchable" | "flattened", source: "original" | "rerender", path: string) =>
|
||||||
|
exportInvoice(
|
||||||
|
{ invoice: inv, mode, source },
|
||||||
|
{
|
||||||
|
...deps,
|
||||||
|
save: async () => path,
|
||||||
|
writeExportFile: async (p, bytes) => {
|
||||||
|
written.set(p, bytes);
|
||||||
|
return deps.writeExportFile(p, bytes);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let inv!: Invoice;
|
||||||
|
const issued = await log.run("e2e: issue and archive", async () => {
|
||||||
|
const outcome = await issueAndArchive(input, prefs, deps);
|
||||||
|
invoice = outcome.invoice;
|
||||||
|
return {
|
||||||
|
ok: outcome.archived,
|
||||||
|
detail: `${outcome.invoice.number} (id ${outcome.invoice.id}), total ${outcome.invoice.total}, archived=${outcome.archived}${outcome.error ? `, error: ${outcome.error}` : ""}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!issued.ok || !invoice) return;
|
||||||
|
inv = invoice as Invoice;
|
||||||
|
|
||||||
|
await log.run("e2e: archive status", async () => {
|
||||||
|
const st = await api.archiveStatus(inv.id);
|
||||||
|
const matches = st.archived && st.sha256 === inv.archivedPdfSha256 && st.fingerprint === inv.archivedFingerprint;
|
||||||
|
return { ok: matches, detail: `archived=${st.archived}, sha ${st.sha256?.slice(0, 12)}, fingerprint ${st.fingerprint?.slice(0, 12)}, matches issue result: ${matches}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: export searchable original", async () => {
|
||||||
|
const r = await exportWith("searchable", "original", searchablePath);
|
||||||
|
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: export flattened original", async () => {
|
||||||
|
const r = await exportWith("flattened", "original", flattenedPath);
|
||||||
|
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: export searchable re-render", async () => {
|
||||||
|
const r = await exportWith("searchable", "rerender", `${outDir}/e2e-rerender.pdf`);
|
||||||
|
return { ok: !!r && r.identicalToIssued !== undefined, detail: `identicalToIssued=${r?.identicalToIssued}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: archive bytes equal the searchable export", async () => {
|
||||||
|
const archived = await api.readArchive(inv.id);
|
||||||
|
const exported = written.get(searchablePath);
|
||||||
|
const eq = !!exported && bytesEqual(archived, exported);
|
||||||
|
return { ok: eq, detail: `${kb(archived.byteLength)} header ${hex(archived)}, equal to export file: ${eq}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: last export directory", async () => {
|
||||||
|
const dir = await api.getLastExportDir();
|
||||||
|
return { ok: dir === outDir, detail: `${dir} (expected ${outDir})` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: cancel keeps archive readable", async () => {
|
||||||
|
const before = await api.readArchive(inv.id);
|
||||||
|
const cancelled = await api.cancelInvoice(inv.id, "self-test");
|
||||||
|
const after = await api.readArchive(inv.id);
|
||||||
|
const st = await api.archiveStatus(inv.id);
|
||||||
|
return {
|
||||||
|
ok: cancelled.status === "cancelled" && st.archived && bytesEqual(before, after),
|
||||||
|
detail: `status ${cancelled.status}, archive still ${kb(after.byteLength)}, unchanged: ${bytesEqual(before, after)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: archiving different bytes is refused", async () => {
|
||||||
|
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
|
||||||
|
const before = await api.readArchive(inv.id);
|
||||||
|
try {
|
||||||
|
await api.archivePdf(inv.id, other, "tampered");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e);
|
||||||
|
const after = await api.readArchive(inv.id);
|
||||||
|
const intact = bytesEqual(after, before);
|
||||||
|
return { ok: /cannot be replaced/.test(msg) && intact, detail: `refused: ${msg}; archive unchanged: ${intact}` };
|
||||||
|
}
|
||||||
|
return { ok: false, detail: "the replacement was accepted" };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { bytesEqual, createStepLog, dirOf } from "./selfTestSteps";
|
||||||
|
|
||||||
|
describe("createStepLog", () => {
|
||||||
|
it("records passes, expectation failures and throws without throwing itself", async () => {
|
||||||
|
let t = 0;
|
||||||
|
const log = createStepLog(() => (t += 10));
|
||||||
|
await log.run("a", async () => "fine");
|
||||||
|
await log.run("b", async () => ({ ok: false, detail: "wrong" }));
|
||||||
|
await log.run("c", async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
});
|
||||||
|
expect(log.steps).toEqual([
|
||||||
|
{ name: "a", ok: true, ms: 10, detail: "fine" },
|
||||||
|
{ name: "b", ok: false, ms: 10, detail: "wrong" },
|
||||||
|
{ name: "c", ok: false, ms: 10, detail: "Error: boom" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirOf / bytesEqual", () => {
|
||||||
|
it("splits posix and windows paths", () => {
|
||||||
|
expect(dirOf("/tmp/x/report.json")).toBe("/tmp/x");
|
||||||
|
expect(dirOf("C:\\out\\report.json")).toBe("C:\\out");
|
||||||
|
expect(dirOf("/report.json")).toBe("/");
|
||||||
|
expect(dirOf("report.json")).toBe("");
|
||||||
|
});
|
||||||
|
it("compares bytes", () => {
|
||||||
|
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true);
|
||||||
|
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false);
|
||||||
|
expect(bytesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/** Step log of the unattended end-to-end self-test: one entry per step with its verdict and timing. */
|
||||||
|
|
||||||
|
export interface StepResult {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
ms: number;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepLog {
|
||||||
|
steps: StepResult[];
|
||||||
|
/**
|
||||||
|
* Runs one step. The body returns its detail text, or throws to fail. A body may also return
|
||||||
|
* `{ ok: false, detail }` for a failed expectation. Never throws; resolves to the recorded result.
|
||||||
|
*/
|
||||||
|
run(name: string, body: () => Promise<string | { ok: boolean; detail: string }>): Promise<StepResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errText = (e: unknown) => (e instanceof Error ? `${e.name}: ${e.message}` : String(e));
|
||||||
|
|
||||||
|
export function createStepLog(now: () => number = () => performance.now()): StepLog {
|
||||||
|
const steps: StepResult[] = [];
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
async run(name, body) {
|
||||||
|
const t0 = now();
|
||||||
|
let result: StepResult;
|
||||||
|
try {
|
||||||
|
const r = await body();
|
||||||
|
const { ok, detail } = typeof r === "string" ? { ok: true, detail: r } : r;
|
||||||
|
result = { name, ok, ms: Math.round(now() - t0), detail };
|
||||||
|
} catch (e) {
|
||||||
|
result = { name, ok: false, ms: Math.round(now() - t0), detail: errText(e) };
|
||||||
|
}
|
||||||
|
steps.push(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The directory part of an absolute path, whichever separator it uses. */
|
||||||
|
export function dirOf(path: string): string {
|
||||||
|
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
||||||
|
return i <= 0 ? path.slice(0, i + 1) : path.slice(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.byteLength !== b.byteLength) return false;
|
||||||
|
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* WebKitGTK (2.50) lacks ReadableStream.prototype[Symbol.asyncIterator], which pdf.js uses in
|
||||||
|
* getTextContent. Installs a minimal version when it is missing; a no-op elsewhere.
|
||||||
|
*/
|
||||||
|
export function installStreamAsyncIterator(): void {
|
||||||
|
const proto = (globalThis as { ReadableStream?: { prototype: object } }).ReadableStream?.prototype as
|
||||||
|
| Record<symbol, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!proto || Symbol.asyncIterator in proto) return;
|
||||||
|
proto[Symbol.asyncIterator] = function (this: ReadableStream<unknown>) {
|
||||||
|
const reader = this.getReader();
|
||||||
|
const iterator = {
|
||||||
|
next: () => reader.read(),
|
||||||
|
return: async () => {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
reader.releaseLock();
|
||||||
|
return { done: true as const, value: undefined };
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return iterator;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return iterator;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ export interface Settings {
|
|||||||
defaultHsnSac: string;
|
defaultHsnSac: string;
|
||||||
signatoryName: string;
|
signatoryName: string;
|
||||||
signatoryDesignation: string;
|
signatoryDesignation: string;
|
||||||
|
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
|
||||||
|
renderPrefs: string;
|
||||||
|
/** Folder of the last export; kept by the backend (saving settings does not change it). */
|
||||||
|
lastExportDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BankAccount {
|
export interface BankAccount {
|
||||||
@@ -132,6 +136,11 @@ export interface Invoice {
|
|||||||
cancelledAt: string | null;
|
cancelledAt: string | null;
|
||||||
cancelReason: string;
|
cancelReason: string;
|
||||||
archivedPdfSha256: string | null;
|
archivedPdfSha256: string | null;
|
||||||
|
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
|
||||||
|
renderPrefs: string;
|
||||||
|
/** Layout fingerprint of the archived PDF; null when nothing is archived. */
|
||||||
|
archivedFingerprint?: string | null;
|
||||||
|
archivedAt?: string | null;
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import React from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/carbon.scss";
|
import "./styles/carbon.scss";
|
||||||
|
import { maybeRunSelfTest } from "./selftestBoot";
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void maybeRunSelfTest();
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
import { createElement } from "react";
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import { InvoiceDocument, type InvoicePdfProps } from "./InvoiceDocument";
|
|
||||||
import { registerPdfFonts } from "./fonts";
|
|
||||||
import { describeBox, findOverlaps, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText";
|
|
||||||
import type { BankAccount, InvoiceItem, Settings } from "../lib/types";
|
|
||||||
|
|
||||||
registerPdfFonts(path.resolve(process.cwd(), "public/fonts"));
|
|
||||||
|
|
||||||
/** Set PDF_OUT_DIR to also write the rendered PDFs there for visual review. */
|
|
||||||
const OUT_DIR = process.env.PDF_OUT_DIR ?? "";
|
|
||||||
|
|
||||||
const logoDataUri = `data:image/png;base64,${readFileSync(
|
|
||||||
path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"),
|
|
||||||
).toString("base64")}`;
|
|
||||||
|
|
||||||
const vendor: Settings = {
|
|
||||||
vendorName: "Test Vendor",
|
|
||||||
vendorAddress: "1 Test Road, Bangalore - 560001",
|
|
||||||
vendorEmail: "vendor@example.com",
|
|
||||||
vendorPhone: "9000000000",
|
|
||||||
vendorPan: "ABCDE1234F",
|
|
||||||
vendorGstin: "",
|
|
||||||
vendorStateCode: "29",
|
|
||||||
logoPath: null,
|
|
||||||
signaturePath: null,
|
|
||||||
defaultBankId: 1,
|
|
||||||
defaultTaxRate: 18,
|
|
||||||
defaultTaxType: "cgst_sgst",
|
|
||||||
paymentTermsDays: 30,
|
|
||||||
currency: "INR",
|
|
||||||
onboarded: true,
|
|
||||||
theme: "g10",
|
|
||||||
gstRegistration: "unregistered",
|
|
||||||
defaultHsnSac: "",
|
|
||||||
signatoryName: "",
|
|
||||||
signatoryDesignation: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const bank: BankAccount = {
|
|
||||||
id: 1,
|
|
||||||
label: "Primary",
|
|
||||||
bankName: "State Bank of India",
|
|
||||||
accountName: "Test Vendor",
|
|
||||||
accountNo: "000000000000",
|
|
||||||
branch: "000001",
|
|
||||||
ifsc: "TEST0000001",
|
|
||||||
isDefault: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeItems = (n: number): InvoiceItem[] =>
|
|
||||||
Array.from({ length: n }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
|
|
||||||
mode: i % 2 === 0 ? "rate" : "fixed",
|
|
||||||
rate: 1200,
|
|
||||||
unit: "hour",
|
|
||||||
quantity: 1 + (i % 3),
|
|
||||||
amount: 2500 + i * 10,
|
|
||||||
sortOrder: i,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
||||||
const lineAmount = (it: InvoiceItem) => (it.mode === "rate" ? it.rate * it.quantity : it.amount);
|
|
||||||
|
|
||||||
function build(over: Partial<InvoicePdfProps> & { items: InvoiceItem[] }): InvoicePdfProps {
|
|
||||||
const subtotal = round2(over.items.reduce((s, it) => s + lineAmount(it), 0));
|
|
||||||
const discount = over.discount ?? 0;
|
|
||||||
const taxType = over.taxType ?? "cgst_sgst";
|
|
||||||
const taxRate = over.taxRate ?? 18;
|
|
||||||
const taxable = subtotal - discount;
|
|
||||||
const tax = taxType === "none" ? 0 : round2((taxable * taxRate) / 100);
|
|
||||||
const cgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
|
|
||||||
const sgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
|
|
||||||
const igst = taxType === "igst" ? tax : 0;
|
|
||||||
return {
|
|
||||||
vendor,
|
|
||||||
bank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri: null,
|
|
||||||
number: "INV-0001",
|
|
||||||
invoiceDate: "2026-09-15",
|
|
||||||
dueDate: "2026-10-15",
|
|
||||||
clientName: "Acme Learning Pvt Ltd",
|
|
||||||
clientAddress: "42 MG Road\nBangalore 560001",
|
|
||||||
clientGstin: "29ABCDE1234F1Z5",
|
|
||||||
poNumber: "",
|
|
||||||
placeOfSupplyStateCode: "29",
|
|
||||||
subtotal,
|
|
||||||
discount,
|
|
||||||
taxType,
|
|
||||||
taxRate,
|
|
||||||
cgst,
|
|
||||||
sgst,
|
|
||||||
igst,
|
|
||||||
total: round2(taxable + cgst + sgst + igst),
|
|
||||||
amountInWords: "Rupees One Lakh Only",
|
|
||||||
notes: "",
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixtureA = () =>
|
|
||||||
build({
|
|
||||||
items: makeItems(3),
|
|
||||||
notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest.",
|
|
||||||
poNumber: "PO-2026-0042",
|
|
||||||
});
|
|
||||||
const fixtureB = () => build({ items: makeItems(60) });
|
|
||||||
const fixtureD = () =>
|
|
||||||
build({
|
|
||||||
items: makeItems(5),
|
|
||||||
logoDataUri: null,
|
|
||||||
signatureDataUri: null,
|
|
||||||
vendor: {
|
|
||||||
...vendor,
|
|
||||||
vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt",
|
|
||||||
vendorEmail: "vendor.name@examplestudios.co.in",
|
|
||||||
vendorAddress: "13-B type House, Test Road\nTest Town, Cooke Town Extension\nBangalore - 560005",
|
|
||||||
},
|
|
||||||
clientAddress: "42 MG Road, Ashok Nagar\nBangalore 560001",
|
|
||||||
taxType: "igst",
|
|
||||||
taxRate: 18,
|
|
||||||
discount: 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
const render = (p: InvoicePdfProps) => renderPdfText(createElement(InvoiceDocument, p));
|
|
||||||
|
|
||||||
const save = (name: string, pdf: RenderedPdf) => {
|
|
||||||
if (!OUT_DIR) return;
|
|
||||||
mkdirSync(OUT_DIR, { recursive: true });
|
|
||||||
writeFileSync(path.join(OUT_DIR, name), pdf.bytes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MARGIN_L = 39.5;
|
|
||||||
const MARGIN_R = 555.8;
|
|
||||||
const find = (items: TextBox[], str: string) => items.find((t) => t.str.trim() === str);
|
|
||||||
/** Letter-spaced labels come out of pdf.js with spaces between glyph runs ("D E S C R I P TI O N"). */
|
|
||||||
const findSpaced = (items: TextBox[], str: string) =>
|
|
||||||
items.find((t) => t.str.replace(/\s+/g, "").toUpperCase() === str.toUpperCase());
|
|
||||||
const textOf = (items: TextBox[]) => items.map((t) => t.str).join(" ");
|
|
||||||
|
|
||||||
/** Assertions that apply to every page of every fixture. */
|
|
||||||
function checkCommon(pdf: RenderedPdf) {
|
|
||||||
const n = pdf.pages.length;
|
|
||||||
expect(pdf.raw).not.toContain("/BaseFont /Helvetica");
|
|
||||||
|
|
||||||
pdf.pages.forEach((page, idx) => {
|
|
||||||
const k = idx + 1;
|
|
||||||
const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`);
|
|
||||||
expect(overlaps, `page ${k} overlaps`).toEqual([]);
|
|
||||||
|
|
||||||
for (const t of page.items) {
|
|
||||||
const isFooter = t.top > 780;
|
|
||||||
expect(t.x0, `page ${k} ${describeBox(t)} left`).toBeGreaterThanOrEqual(MARGIN_L);
|
|
||||||
expect(t.x1, `page ${k} ${describeBox(t)} right`).toBeLessThanOrEqual(MARGIN_R);
|
|
||||||
if (isFooter) expect(t.bottom, `page ${k} ${describeBox(t)} bottom`).toBeLessThanOrEqual(842);
|
|
||||||
else expect(t.top, `page ${k} ${describeBox(t)} top`).toBeGreaterThanOrEqual(MARGIN_L);
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = `Page ${k} of ${n}`;
|
|
||||||
const pageLabels = page.items.filter((t) => /^Page \d+ of \d+$/.test(t.str.trim()));
|
|
||||||
expect(pageLabels.map((t) => t.str.trim()), `page ${k} page label`).toEqual([label]);
|
|
||||||
expect(
|
|
||||||
page.items.filter((t) => t.str.includes("Generated with Voiced")).length,
|
|
||||||
`page ${k} footer`,
|
|
||||||
).toBe(1);
|
|
||||||
|
|
||||||
const text = textOf(page.items);
|
|
||||||
expect(text).not.toContain("¹");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkHeader(pdf: RenderedPdf, hasDue = true) {
|
|
||||||
const { items } = pdf.pages[0];
|
|
||||||
const metaStrs = ["Invoice No.", "Date", ...(hasDue ? ["Due Date"] : [])];
|
|
||||||
const metaIdx = items.filter((t) => t.top < 150 && (t.str.trim() === "INVOICE" || /TAX INVOICE/i.test(t.str)));
|
|
||||||
const title = metaIdx[0] ?? find(items, "INVOICE");
|
|
||||||
expect(title, "title").toBeDefined();
|
|
||||||
const meta: TextBox[] = [title as TextBox];
|
|
||||||
for (const s of metaStrs) {
|
|
||||||
const lab = find(items, s);
|
|
||||||
expect(lab, s).toBeDefined();
|
|
||||||
meta.push(lab as TextBox);
|
|
||||||
// value: item on the same baseline to the right of the label
|
|
||||||
const val = items.find((t) => t !== lab && Math.abs(t.bottom - (lab as TextBox).bottom) < 3 && t.x0 > (lab as TextBox).x1 - 0.5);
|
|
||||||
expect(val, `${s} value`).toBeDefined();
|
|
||||||
meta.push(val as TextBox);
|
|
||||||
}
|
|
||||||
for (const m of meta) expect(m.x0, describeBox(m)).toBeGreaterThanOrEqual(362.5);
|
|
||||||
const headerBottom = Math.max(...meta.map((m) => m.bottom));
|
|
||||||
for (const t of items) {
|
|
||||||
if (meta.includes(t) || t.top > headerBottom + 20) continue;
|
|
||||||
if (t.x0 < 339.5) expect(t.x1, `header left ${describeBox(t)}`).toBeLessThanOrEqual(339.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkRupee(pdf: RenderedPdf) {
|
|
||||||
const all = pdf.pages.flatMap((p) => p.items);
|
|
||||||
const total = all.filter((t) => t.str.includes("Subtotal") || t.str.trim() === "Total");
|
|
||||||
expect(total.length).toBeGreaterThan(0);
|
|
||||||
const amounts = all.filter((t) => /\d\.\d\d$/.test(t.str.trim()));
|
|
||||||
expect(amounts.length).toBeGreaterThan(0);
|
|
||||||
for (const a of amounts) expect(a.str, describeBox(a)).toContain("₹");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Table-structure checks for multi-page documents. */
|
|
||||||
function checkTable(pdf: RenderedPdf) {
|
|
||||||
let totalPage = -1;
|
|
||||||
let subtotalPage = -1;
|
|
||||||
pdf.pages.forEach((page, idx) => {
|
|
||||||
const k = idx + 1;
|
|
||||||
const head = findSpaced(page.items, "DESCRIPTION");
|
|
||||||
const isTotals = (t: TextBox) => {
|
|
||||||
// amounts at or below the totals block start
|
|
||||||
const sub = find(page.items, "Subtotal");
|
|
||||||
return !!sub && t.top >= sub.top - 1;
|
|
||||||
};
|
|
||||||
const rows = page.items.filter(
|
|
||||||
(t) => /^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.x0 > 440 && !isTotals(t) && (!head || t.top > head.top),
|
|
||||||
);
|
|
||||||
if (head) {
|
|
||||||
expect(rows.length, `page ${k} has header but no rows`).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
if (idx > 0 && rows.length > 0) expect(head, `page ${k} header repeats`).toBeDefined();
|
|
||||||
if (find(page.items, "Subtotal")) subtotalPage = k;
|
|
||||||
if (find(page.items, "Total")) totalPage = k;
|
|
||||||
});
|
|
||||||
expect(subtotalPage).toBeGreaterThan(0);
|
|
||||||
expect(totalPage, "Subtotal and Total on same page").toBe(subtotalPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture A (3 items, stacked logo)", () => {
|
|
||||||
let pdf: RenderedPdf;
|
|
||||||
beforeAll(async () => {
|
|
||||||
pdf = await render(fixtureA());
|
|
||||||
save("new-A.pdf", pdf);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a single page", () => expect(pdf.pages.length).toBe(1));
|
|
||||||
it("has no overlaps, margin breaches, stray Helvetica, page labels or footer gaps", () => checkCommon(pdf));
|
|
||||||
it("keeps header columns apart", () => checkHeader(pdf));
|
|
||||||
it("prints rupee signs", () => checkRupee(pdf));
|
|
||||||
it("has table structure", () => checkTable(pdf));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture B (60 items)", () => {
|
|
||||||
let pdf: RenderedPdf;
|
|
||||||
beforeAll(async () => {
|
|
||||||
pdf = await render(fixtureB());
|
|
||||||
save("new-B-60rows.pdf", pdf);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("spans multiple pages", () => expect(pdf.pages.length).toBeGreaterThan(1));
|
|
||||||
it("common checks", () => checkCommon(pdf));
|
|
||||||
it("header", () => checkHeader(pdf));
|
|
||||||
it("rupee", () => checkRupee(pdf));
|
|
||||||
it("table", () => checkTable(pdf));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture C (1..45 items sweep)", () => {
|
|
||||||
it("never strands a header and always keeps Subtotal/Total together", async () => {
|
|
||||||
for (let n = 1; n <= 45; n++) {
|
|
||||||
const pdf = await render(build({ items: makeItems(n) }));
|
|
||||||
try {
|
|
||||||
checkCommon(pdf);
|
|
||||||
checkHeader(pdf);
|
|
||||||
checkRupee(pdf);
|
|
||||||
checkTable(pdf);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`items=${n} (${pdf.pages.length} pages): ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture D (long vendor strings, no logo, IGST, discount)", () => {
|
|
||||||
let pdf: RenderedPdf;
|
|
||||||
beforeAll(async () => {
|
|
||||||
pdf = await render(fixtureD());
|
|
||||||
save("new-D.pdf", pdf);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("common checks", () => checkCommon(pdf));
|
|
||||||
it("header", () => checkHeader(pdf));
|
|
||||||
it("rupee", () => checkRupee(pdf));
|
|
||||||
it("table", () => checkTable(pdf));
|
|
||||||
it("shows IGST and discount", () => {
|
|
||||||
const text = textOf(pdf.pages.flatMap((p) => p.items));
|
|
||||||
expect(text).toContain("IGST");
|
|
||||||
expect(text).toContain("Discount");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import { Document, Image, Page, StyleSheet, Text, View, type TextProps } from "@react-pdf/renderer";
|
|
||||||
import type { ComponentProps, PropsWithChildren } from "react";
|
|
||||||
import { UNIT_LABELS, type BankAccount, type InvoiceItem, type Settings, type TaxType } from "../lib/types";
|
|
||||||
import { computeLineAmount } from "../lib/invoice";
|
|
||||||
import { imageSizeFromDataUri, type ImageSize } from "../lib/imageSize";
|
|
||||||
|
|
||||||
const amountFmt = new Intl.NumberFormat("en-IN", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const money = (n: number) => `${"₹"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
|
|
||||||
|
|
||||||
const dateFmt = (iso: string) => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(`${iso}T00:00:00`);
|
|
||||||
if (Number.isNaN(d.getTime())) return iso;
|
|
||||||
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One entry per line, so no Text ever contains "\n" (react-pdf pulls in an unembedded Helvetica for it). */
|
|
||||||
const splitLines = (value: string): string[] =>
|
|
||||||
value
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export interface InvoicePdfProps {
|
|
||||||
vendor: Settings;
|
|
||||||
bank: BankAccount | null;
|
|
||||||
logoDataUri: string | null;
|
|
||||||
signatureDataUri: string | null;
|
|
||||||
number: string;
|
|
||||||
invoiceDate: string;
|
|
||||||
dueDate: string;
|
|
||||||
clientName: string;
|
|
||||||
clientAddress: string;
|
|
||||||
clientGstin: string;
|
|
||||||
poNumber: string;
|
|
||||||
placeOfSupplyStateCode: string;
|
|
||||||
items: InvoiceItem[];
|
|
||||||
subtotal: number;
|
|
||||||
discount: number;
|
|
||||||
taxType: TaxType;
|
|
||||||
taxRate: number;
|
|
||||||
cgst: number;
|
|
||||||
sgst: number;
|
|
||||||
igst: number;
|
|
||||||
total: number;
|
|
||||||
amountInWords: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Layout rules (see the rendering notes in the project plan):
|
|
||||||
* - Every child of a flex row has an explicit width (width = minWidth = maxWidth), or is the single
|
|
||||||
* fill child with { flexGrow: 1, flexBasis: 0 }. react-pdf wraps text once, at the first measure;
|
|
||||||
* a box that shrinks afterwards keeps its stale lines and the text overflows into its neighbour.
|
|
||||||
* - No lineHeight on the Page or on any View. Every static Text sets fontSize and an absolute
|
|
||||||
* lineHeight ("Npt") on the same node. The render-prop footer Text has no lineHeight anywhere above it.
|
|
||||||
* - Never pin the height of a box that holds text; textkit truncates to the box height.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// A4 content box: 595.28 pt wide minus 2 x 40 pt page padding.
|
|
||||||
const CONTENT_W = 515;
|
|
||||||
const SUPPLIER_W = 299;
|
|
||||||
const HEADER_GAP = 24;
|
|
||||||
const META_W = CONTENT_W - SUPPLIER_W - HEADER_GAP;
|
|
||||||
const HALF_W = 245;
|
|
||||||
const LOGO_MAX_W = 170;
|
|
||||||
const LOGO_MAX_H = 54;
|
|
||||||
const LOGO_GAP = 14;
|
|
||||||
const MIN_TEXT_W = 160;
|
|
||||||
|
|
||||||
const pinned = (w: number) => ({ width: w, minWidth: w, maxWidth: w });
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
page: {
|
|
||||||
fontFamily: "IBM Plex Sans",
|
|
||||||
fontSize: 9.5,
|
|
||||||
color: "#161616",
|
|
||||||
paddingTop: 40,
|
|
||||||
paddingBottom: 48,
|
|
||||||
paddingHorizontal: 40,
|
|
||||||
},
|
|
||||||
body: { fontSize: 9.5, lineHeight: "13.8pt" },
|
|
||||||
header: { flexDirection: "row", alignItems: "flex-start" },
|
|
||||||
supplier: { ...pinned(SUPPLIER_W) },
|
|
||||||
supplierRow: { flexDirection: "row" },
|
|
||||||
supplierStacked: { flexDirection: "column" },
|
|
||||||
supplierTextRow: { flexGrow: 1, flexBasis: 0 },
|
|
||||||
supplierTextStacked: { ...pinned(SUPPLIER_W) },
|
|
||||||
vendorName: { fontSize: 18, lineHeight: "22pt", fontWeight: 600, marginBottom: 3 },
|
|
||||||
vendorLine: { color: "#393939" },
|
|
||||||
meta: { ...pinned(META_W), marginLeft: HEADER_GAP, textAlign: "right" },
|
|
||||||
// marginRight cancels the letterSpacing react-pdf adds after the last glyph, so the title's
|
|
||||||
// right edge lines up with the values below it.
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
lineHeight: "22pt",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: 1.5,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: 8,
|
|
||||||
marginRight: -1.5,
|
|
||||||
},
|
|
||||||
metaRow: { flexDirection: "row", alignItems: "flex-start" },
|
|
||||||
metaLabel: { ...pinned(66), textAlign: "left", color: "#525252" },
|
|
||||||
metaValue: { flexGrow: 1, flexBasis: 0, textAlign: "right", fontWeight: 600 },
|
|
||||||
divider: { borderBottomWidth: 1, borderBottomColor: "#e0e0e0", marginTop: 16, marginBottom: 16 },
|
|
||||||
split: { flexDirection: "row", gap: HEADER_GAP },
|
|
||||||
blockLabel: {
|
|
||||||
fontSize: 7.5,
|
|
||||||
lineHeight: "10pt",
|
|
||||||
letterSpacing: 1,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "#525252",
|
|
||||||
marginBottom: 3,
|
|
||||||
},
|
|
||||||
strong: { fontWeight: 600 },
|
|
||||||
half: { ...pinned(HALF_W) },
|
|
||||||
// No border or padding on the table container: with one, react-pdf can leave a page that holds
|
|
||||||
// only the repeated header row. The top rule belongs to the header row instead.
|
|
||||||
table: { marginTop: 22 },
|
|
||||||
tr: {
|
|
||||||
flexDirection: "row",
|
|
||||||
borderBottomWidth: 0.6,
|
|
||||||
borderBottomColor: "#e0e0e0",
|
|
||||||
paddingVertical: 6,
|
|
||||||
},
|
|
||||||
trHead: { borderTopWidth: 1.4, borderTopColor: "#161616", borderBottomColor: "#161616" },
|
|
||||||
th: {
|
|
||||||
fontSize: 7.5,
|
|
||||||
lineHeight: "10pt",
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "#525252",
|
|
||||||
paddingBottom: 4,
|
|
||||||
},
|
|
||||||
colDesc: { flex: 5, paddingRight: 8 },
|
|
||||||
colRate: { flex: 2, textAlign: "right" },
|
|
||||||
colQty: { flex: 2, textAlign: "right" },
|
|
||||||
colTotal: { flex: 2, textAlign: "right" },
|
|
||||||
totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 },
|
|
||||||
totals: { ...pinned(250) },
|
|
||||||
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
|
|
||||||
grand: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
borderTopWidth: 1.4,
|
|
||||||
borderTopColor: "#161616",
|
|
||||||
marginTop: 4,
|
|
||||||
paddingTop: 6,
|
|
||||||
},
|
|
||||||
grandText: { fontSize: 13, lineHeight: "17pt", fontWeight: 600 },
|
|
||||||
words: { marginTop: 12, fontStyle: "italic" },
|
|
||||||
notes: { marginTop: 14 },
|
|
||||||
bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginTop: 34 },
|
|
||||||
bank: { ...pinned(280) },
|
|
||||||
signature: { ...pinned(200), textAlign: "right" },
|
|
||||||
signatureImg: { maxHeight: 58, maxWidth: 190, objectFit: "contain", marginLeft: "auto" },
|
|
||||||
signatureName: {
|
|
||||||
marginTop: 4,
|
|
||||||
paddingTop: 3,
|
|
||||||
borderTopWidth: 0.8,
|
|
||||||
borderTopColor: "#8d8d8d",
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
fineprint: { fontSize: 7.5, lineHeight: "10.5pt", color: "#6f6f6f", textAlign: "right", marginTop: 6 },
|
|
||||||
// The footer holds the page-number render prop, so nothing in it sets a lineHeight and it has
|
|
||||||
// no fixed height: react-pdf multiplies a render-prop lineHeight again on every relayout, and a
|
|
||||||
// fixed-height box draws no text when the line is taller than the box.
|
|
||||||
footer: {
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 24,
|
|
||||||
left: 40,
|
|
||||||
right: 40,
|
|
||||||
flexDirection: "row",
|
|
||||||
borderTopWidth: 0.6,
|
|
||||||
borderTopColor: "#e0e0e0",
|
|
||||||
paddingTop: 6,
|
|
||||||
},
|
|
||||||
footerLeft: {
|
|
||||||
...pinned(170),
|
|
||||||
fontSize: 7.5,
|
|
||||||
color: "#6f6f6f",
|
|
||||||
maxLines: 1,
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
},
|
|
||||||
footerCenter: { flexGrow: 1, flexBasis: 0, fontSize: 7.5, color: "#6f6f6f", textAlign: "center" },
|
|
||||||
footerRight: { ...pinned(90), fontSize: 7.5, color: "#6f6f6f", textAlign: "right" },
|
|
||||||
});
|
|
||||||
|
|
||||||
type TxtProps = PropsWithChildren<TextProps>;
|
|
||||||
|
|
||||||
/** Text with the body size and an absolute line height; later styles override both together. */
|
|
||||||
function Txt({ style, ...rest }: TxtProps) {
|
|
||||||
const extra = Array.isArray(style) ? style : style ? [style] : [];
|
|
||||||
return <Text {...rest} style={[styles.body, ...extra]} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogoPlan {
|
|
||||||
stacked: boolean;
|
|
||||||
style: ComponentProps<typeof Image>["style"];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Size the logo from its own aspect inside a 170 x 54 pt box. When it would leave the supplier
|
|
||||||
* text less than 160 pt, the logo goes above the text instead of beside it.
|
|
||||||
*/
|
|
||||||
function planLogo(size: ImageSize | null): LogoPlan {
|
|
||||||
if (!size) {
|
|
||||||
return {
|
|
||||||
stacked: true,
|
|
||||||
style: {
|
|
||||||
...pinned(LOGO_MAX_W),
|
|
||||||
height: LOGO_MAX_H,
|
|
||||||
objectFit: "contain",
|
|
||||||
objectPositionX: 0,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const aspect = size.width / size.height;
|
|
||||||
const h = Math.min(LOGO_MAX_H, LOGO_MAX_W / aspect);
|
|
||||||
const w = h * aspect;
|
|
||||||
const stacked = SUPPLIER_W - w - LOGO_GAP < MIN_TEXT_W;
|
|
||||||
return {
|
|
||||||
stacked,
|
|
||||||
style: {
|
|
||||||
...pinned(w),
|
|
||||||
height: h,
|
|
||||||
objectFit: "contain",
|
|
||||||
marginRight: stacked ? 0 : LOGO_GAP,
|
|
||||||
marginBottom: stacked ? 8 : 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InvoiceDocument(props: InvoicePdfProps) {
|
|
||||||
const {
|
|
||||||
vendor,
|
|
||||||
bank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri,
|
|
||||||
number,
|
|
||||||
invoiceDate,
|
|
||||||
dueDate,
|
|
||||||
clientName,
|
|
||||||
clientAddress,
|
|
||||||
clientGstin,
|
|
||||||
poNumber,
|
|
||||||
placeOfSupplyStateCode,
|
|
||||||
items,
|
|
||||||
subtotal,
|
|
||||||
discount,
|
|
||||||
taxType,
|
|
||||||
taxRate,
|
|
||||||
cgst,
|
|
||||||
sgst,
|
|
||||||
igst,
|
|
||||||
total,
|
|
||||||
amountInWords,
|
|
||||||
notes,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const isTaxInvoice = taxType !== "none";
|
|
||||||
const logo = logoDataUri ? planLogo(imageSizeFromDataUri(logoDataUri)) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}>
|
|
||||||
<Page size="A4" style={styles.page}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={[styles.supplier, logo?.stacked ? styles.supplierStacked : styles.supplierRow]}>
|
|
||||||
{logoDataUri && logo ? <Image style={logo.style} src={logoDataUri} /> : null}
|
|
||||||
<View style={logo?.stacked ? styles.supplierTextStacked : styles.supplierTextRow}>
|
|
||||||
<Txt style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Txt>
|
|
||||||
{splitLines(vendor.vendorAddress).map((line, i) => (
|
|
||||||
<Txt key={`addr-${i}`} style={styles.vendorLine}>
|
|
||||||
{line}
|
|
||||||
</Txt>
|
|
||||||
))}
|
|
||||||
{vendor.vendorEmail ? <Txt style={styles.vendorLine}>{vendor.vendorEmail}</Txt> : null}
|
|
||||||
{vendor.vendorPhone ? <Txt style={styles.vendorLine}>{vendor.vendorPhone}</Txt> : null}
|
|
||||||
{vendor.vendorPan ? <Txt style={styles.vendorLine}>PAN: {vendor.vendorPan}</Txt> : null}
|
|
||||||
{vendor.vendorGstin ? <Txt style={styles.vendorLine}>GSTIN: {vendor.vendorGstin}</Txt> : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.meta}>
|
|
||||||
<Txt style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Txt>
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Txt style={styles.metaLabel}>Invoice No.</Txt>
|
|
||||||
<Txt style={styles.metaValue}>{number}</Txt>
|
|
||||||
</View>
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Txt style={styles.metaLabel}>Date</Txt>
|
|
||||||
<Txt style={styles.metaValue}>{dateFmt(invoiceDate)}</Txt>
|
|
||||||
</View>
|
|
||||||
{dueDate ? (
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Txt style={styles.metaLabel}>Due Date</Txt>
|
|
||||||
<Txt style={styles.metaValue}>{dateFmt(dueDate)}</Txt>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.divider} />
|
|
||||||
|
|
||||||
<View style={styles.split}>
|
|
||||||
<View style={styles.half}>
|
|
||||||
<Txt style={styles.blockLabel}>Billed To</Txt>
|
|
||||||
<Txt style={styles.strong}>{clientName || "—"}</Txt>
|
|
||||||
{splitLines(clientAddress).map((line, i) => (
|
|
||||||
<Txt key={`client-${i}`}>{line}</Txt>
|
|
||||||
))}
|
|
||||||
{clientGstin ? <Txt>GSTIN: {clientGstin}</Txt> : null}
|
|
||||||
{placeOfSupplyStateCode ? <Txt>Place of Supply: {placeOfSupplyStateCode}</Txt> : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.half}>
|
|
||||||
{poNumber ? (
|
|
||||||
<>
|
|
||||||
<Txt style={styles.blockLabel}>PO Number</Txt>
|
|
||||||
<Txt style={styles.strong}>{poNumber}</Txt>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Zero-height guard: keeps the header row from being stranded at the foot of a page. */}
|
|
||||||
<View minPresenceAhead={48} />
|
|
||||||
<View style={styles.table}>
|
|
||||||
<View style={[styles.tr, styles.trHead]} fixed>
|
|
||||||
<Txt style={[styles.th, styles.colDesc]}>Description</Txt>
|
|
||||||
<Txt style={[styles.th, styles.colRate]}>Unit Price</Txt>
|
|
||||||
<Txt style={[styles.th, styles.colQty]}>Qty</Txt>
|
|
||||||
<Txt style={[styles.th, styles.colTotal]}>Total</Txt>
|
|
||||||
</View>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<View style={styles.tr} key={item.id ?? i} wrap={false}>
|
|
||||||
<View style={styles.colDesc}>
|
|
||||||
{(splitLines(item.description).length ? splitLines(item.description) : ["—"]).map(
|
|
||||||
(line, li) => (
|
|
||||||
<Txt key={`desc-${li}`}>{line}</Txt>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Txt style={styles.colRate}>
|
|
||||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
|
||||||
</Txt>
|
|
||||||
<Txt style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Txt>
|
|
||||||
<Txt style={styles.colTotal}>{money(computeLineAmount(item))}</Txt>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.totalsWrap} wrap={false}>
|
|
||||||
<View style={styles.totals}>
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>Subtotal</Txt>
|
|
||||||
<Txt>{money(subtotal)}</Txt>
|
|
||||||
</View>
|
|
||||||
{discount > 0 ? (
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>Discount</Txt>
|
|
||||||
<Txt>- {money(discount)}</Txt>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{taxType === "cgst_sgst" ? (
|
|
||||||
<>
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>CGST ({(taxRate / 2).toFixed(2)}%)</Txt>
|
|
||||||
<Txt>{money(cgst)}</Txt>
|
|
||||||
</View>
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>SGST ({(taxRate / 2).toFixed(2)}%)</Txt>
|
|
||||||
<Txt>{money(sgst)}</Txt>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{taxType === "igst" ? (
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>IGST ({taxRate.toFixed(2)}%)</Txt>
|
|
||||||
<Txt>{money(igst)}</Txt>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View style={styles.grand}>
|
|
||||||
<Txt style={styles.grandText}>Total</Txt>
|
|
||||||
<Txt style={styles.grandText}>{money(total)}</Txt>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Txt style={styles.words} wrap={false}>
|
|
||||||
Amount in words: {amountInWords}
|
|
||||||
</Txt>
|
|
||||||
|
|
||||||
{notes ? (
|
|
||||||
<View style={styles.notes} wrap={false}>
|
|
||||||
<Txt style={styles.blockLabel}>Notes</Txt>
|
|
||||||
{splitLines(notes).map((line, i) => (
|
|
||||||
<Txt key={`note-${i}`}>{line}</Txt>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.bottom} wrap={false}>
|
|
||||||
<View style={styles.bank}>
|
|
||||||
{bank ? (
|
|
||||||
<>
|
|
||||||
<Txt style={styles.blockLabel}>Pay To</Txt>
|
|
||||||
<Txt style={styles.strong}>{bank.accountName || vendor.vendorName}</Txt>
|
|
||||||
<Txt>{bank.bankName}</Txt>
|
|
||||||
{bank.accountNo ? <Txt>Account No.: {bank.accountNo}</Txt> : null}
|
|
||||||
{bank.branch ? <Txt>Branch: {bank.branch}</Txt> : null}
|
|
||||||
{bank.ifsc ? <Txt>IFSC: {bank.ifsc}</Txt> : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.signature}>
|
|
||||||
{signatureDataUri ? (
|
|
||||||
<>
|
|
||||||
<Image style={styles.signatureImg} src={signatureDataUri} />
|
|
||||||
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
|
|
||||||
<Txt style={styles.fineprint}>Computer-generated invoice; no signature is required.</Txt>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.footer} fixed>
|
|
||||||
<Text style={styles.footerLeft}>{vendor.vendorName}</Text>
|
|
||||||
<Text style={styles.footerCenter}>{"Generated with Voiced — a Bongbetic product"}</Text>
|
|
||||||
<Text
|
|
||||||
style={styles.footerRight}
|
|
||||||
render={({ pageNumber, totalPages }) =>
|
|
||||||
totalPages ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Page>
|
|
||||||
</Document>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Page, View } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { PageFrame } from "./engine/geometry";
|
||||||
|
import { chromeHeight } from "./engine/metrics";
|
||||||
|
import type { RenderModel } from "./model/build";
|
||||||
|
import { AvailableWidthProvider } from "./blocks/AvailableWidth";
|
||||||
|
import { PageChrome } from "./blocks/PageChrome";
|
||||||
|
|
||||||
|
/** Height the footer strip takes inside the bottom padding: the text line, a 6 pt gap and the rule. */
|
||||||
|
export const FOOTER_RESERVE = 24;
|
||||||
|
/** Room above the content on pages 2+ for the continuation band, inside the top margin. */
|
||||||
|
export const CONTINUATION_RESERVE = 18;
|
||||||
|
|
||||||
|
const CHROME_INK = "#6f6f6f";
|
||||||
|
const RULE = "#e0e0e0";
|
||||||
|
const FOOTER_LEFT_WIDTH = 170;
|
||||||
|
|
||||||
|
export interface InvoicePageProps {
|
||||||
|
frame: PageFrame;
|
||||||
|
model: RenderModel;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only `<Page>` in the app: size and padding come from the frame (no page-size or margin literal
|
||||||
|
* anywhere else), the flow is the template's, and the chrome (footer, continuation band) is added after
|
||||||
|
* it as fixed page-level boxes. No lineHeight here: it would reach the render-prop page-number text.
|
||||||
|
*/
|
||||||
|
export function InvoicePage({ frame, model, children }: InvoicePageProps) {
|
||||||
|
const { pad } = frame;
|
||||||
|
const marginBottom = pad.bottom - frame.footerReserve;
|
||||||
|
const ruleY = frame.page.height - marginBottom - chromeHeight("footer") - 6.6;
|
||||||
|
const bandLeft = `${model.docTitle} ${model.meta.number} · ${model.meta.date}`;
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
size={frame.page.size}
|
||||||
|
style={{ paddingTop: pad.top, paddingRight: pad.right, paddingBottom: pad.bottom, paddingLeft: pad.left, color: "#161616" }}
|
||||||
|
>
|
||||||
|
<AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider>
|
||||||
|
<PageChrome
|
||||||
|
frame={frame}
|
||||||
|
footerLeft={model.supplier.name}
|
||||||
|
footerLeftWidth={FOOTER_LEFT_WIDTH}
|
||||||
|
footerCenter={model.footerText}
|
||||||
|
color={CHROME_INK}
|
||||||
|
band={{ left: bandLeft, right: "continued" }}
|
||||||
|
decor={[{ x: pad.left, y: ruleY, width: frame.contentWidth, height: 0.6, children: <View style={{ height: 0.6, backgroundColor: RULE }} /> }]}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Document } from "@react-pdf/renderer";
|
||||||
|
import type { PageFrame } from "./engine/geometry";
|
||||||
|
import type { RenderModel } from "./model/build";
|
||||||
|
import type { RenderPrefsV1 } from "./model/prefs";
|
||||||
|
import type { PlacedLogo, TemplateDefinition } from "./templates/contract";
|
||||||
|
import { InvoicePage } from "./InvoicePage";
|
||||||
|
|
||||||
|
export const PDF_PRODUCER = "Voiced / react-pdf 4.9.0";
|
||||||
|
|
||||||
|
export interface InvoicePdfDocProps {
|
||||||
|
model: RenderModel;
|
||||||
|
frame: PageFrame;
|
||||||
|
template: TemplateDefinition;
|
||||||
|
logo: PlacedLogo | null;
|
||||||
|
prefs: RenderPrefsV1;
|
||||||
|
/** Forwarded to the Document; renderCore reads the layout tree from it. */
|
||||||
|
onRender?: (props: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The invoice date at 00:00 UTC. Fixed dates make the bytes of one invoice deterministic. */
|
||||||
|
function documentDate(iso: string): Date {
|
||||||
|
const d = new Date(`${iso}T00:00:00Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? new Date(0) : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicePdf({ model, frame, template, logo, prefs, onRender }: InvoicePdfDocProps) {
|
||||||
|
const Layout = template.Layout;
|
||||||
|
const date = documentDate(model.meta.isoDate);
|
||||||
|
return (
|
||||||
|
<Document
|
||||||
|
title={model.pdfTitle}
|
||||||
|
author={model.supplier.name}
|
||||||
|
subject={`${model.docTitle} ${model.meta.number} for ${model.client.name}`}
|
||||||
|
keywords={`${model.meta.number}, ${model.client.name}`}
|
||||||
|
creator={`Voiced ${model.appVersion} (${template.id} v${template.version})`}
|
||||||
|
producer={PDF_PRODUCER}
|
||||||
|
language="en-IN"
|
||||||
|
creationDate={date}
|
||||||
|
modificationDate={date}
|
||||||
|
onRender={onRender as never}
|
||||||
|
>
|
||||||
|
<InvoicePage frame={frame} model={model}>
|
||||||
|
<Layout model={model} frame={frame} logo={logo} prefs={prefs} />
|
||||||
|
</InvoicePage>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createElement as h, type ReactElement } from "react";
|
||||||
|
import { Document, Image, Page } from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
export interface AssemblePage {
|
||||||
|
/** A baseline JPEG; react-pdf embeds it as is (DCTDecode passthrough, no re-encoding). */
|
||||||
|
jpeg: Uint8Array;
|
||||||
|
widthPt: number;
|
||||||
|
heightPt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembleInput {
|
||||||
|
pages: AssemblePage[];
|
||||||
|
/** The only metadata written: a flattened file must not leak the client name or GSTINs. */
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An image-only document: one full-bleed JPEG per page. No author, subject or keywords, and the
|
||||||
|
* title is whatever the caller passes (the invoice number). The images go in as untyped Blobs because
|
||||||
|
* that is the form react-pdf's browser build resolves without sniffing the type.
|
||||||
|
*/
|
||||||
|
export function assembleImagePdf({ pages, title }: AssembleInput): ReactElement {
|
||||||
|
return h(
|
||||||
|
Document,
|
||||||
|
{ title },
|
||||||
|
...pages.map((p, i) =>
|
||||||
|
h(
|
||||||
|
Page,
|
||||||
|
{ key: i, size: [p.widthPt, p.heightPt] as [number, number], style: { padding: 0 }, wrap: false },
|
||||||
|
h(Image, {
|
||||||
|
src: new Blob([p.jpeg as BlobPart]) as never,
|
||||||
|
style: { width: p.widthPt, height: p.heightPt },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const Ctx = createContext<number | null>(null);
|
||||||
|
|
||||||
|
/** Publishes the content width in pt of the box its children are laid out in. */
|
||||||
|
export function AvailableWidthProvider({ width, children }: { width: number; children?: ReactNode }) {
|
||||||
|
return <Ctx.Provider value={width}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content width (box width minus its padding and border) available to the calling component. */
|
||||||
|
export function useAvailableWidth(): number {
|
||||||
|
const w = useContext(Ctx);
|
||||||
|
if (w === null) throw new Error("useAvailableWidth outside an AvailableWidthProvider (the page frame provides the root)");
|
||||||
|
return w;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { FILL } from "../engine/columns";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elastic gap before the closing block (amount in words, bank, signatory). It grows to push the closing
|
||||||
|
* block to the foot of whichever page it ends on. `closingH` is the closing block's height: as
|
||||||
|
* minPresenceAhead it keeps the spacer from staying on the previous page while the block starts the
|
||||||
|
* next one (without it the spacer stays behind and the block sits at the top of the new page).
|
||||||
|
*/
|
||||||
|
export function BottomSpacer({ closingH }: { closingH: number }) {
|
||||||
|
return <View style={FILL} {...pdfFlags({ minPresenceAhead: closingH })} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { DECOR_ID } from "../engine/ids";
|
||||||
|
import type { Sides } from "../engine/geometry";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
|
||||||
|
/** A decorative box placed in PAGE space (pt from the page's top-left corner). */
|
||||||
|
export interface DecorItem {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstPageHeaderProps {
|
||||||
|
/** The page padding from the frame: a header that is first in the flow starts at (pad.left, pad.top). */
|
||||||
|
pad: Sides;
|
||||||
|
/** Page-space position of the header's top-left corner; defaults to (pad.left, pad.top). */
|
||||||
|
pageOrigin?: { x: number; y: number };
|
||||||
|
/** First-page-only decor, in page space. Converted here to offsets from the header's own corner. */
|
||||||
|
decor?: DecorItem[];
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First thing in the flow, `wrap={false}`. First-page-only decor (Svg, Image, bands) lives here as
|
||||||
|
* absolute children instead of at page level: a non-fixed absolute Svg at page level that straddles the
|
||||||
|
* bottom margin makes react-pdf's paginate() loop forever, or drags all later content to the next page.
|
||||||
|
* Inside this unbreakable block it is laid out once with the header, on page 1.
|
||||||
|
*
|
||||||
|
* Absolute offsets are measured from the header's own corner; the header's padding does not shift them.
|
||||||
|
*/
|
||||||
|
export function FirstPageHeader({ pad, pageOrigin, decor = [], style, children }: FirstPageHeaderProps) {
|
||||||
|
const ox = pageOrigin?.x ?? pad.left;
|
||||||
|
const oy = pageOrigin?.y ?? pad.top;
|
||||||
|
return (
|
||||||
|
<View style={style as never} {...pdfFlags({ wrap: false })}>
|
||||||
|
{decor.map((d, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
id={DECOR_ID}
|
||||||
|
style={{ position: "absolute", left: d.x - ox, top: d.y - oy, width: d.width, height: d.height }}
|
||||||
|
>
|
||||||
|
{d.children}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import { minSizeOf, resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface FitInfo {
|
||||||
|
size: number;
|
||||||
|
fits: boolean;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitTextProps extends Omit<TxtProps, "children" | "role" | "maxLines" | "width"> {
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
/** The width the single line must fit, pt. */
|
||||||
|
width: number;
|
||||||
|
/** Smallest size to shrink to; defaults to the role's minimum size. */
|
||||||
|
minSize?: number;
|
||||||
|
children: string;
|
||||||
|
/** For tests and audits only. */
|
||||||
|
onFit?: (info: FitInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP = 0.25;
|
||||||
|
|
||||||
|
/** Size at which `text` fits `width` on one line, shrinking from the role size in 0.25 pt steps. */
|
||||||
|
export function fitSize(
|
||||||
|
role: RoleName | TypeToken,
|
||||||
|
text: string,
|
||||||
|
width: number,
|
||||||
|
minSize?: number,
|
||||||
|
opts: { weight?: number; italic?: boolean; family?: string } = {},
|
||||||
|
): FitInfo {
|
||||||
|
const token = roleToken(role);
|
||||||
|
const m = getMeasurer();
|
||||||
|
const floor = Math.min(token.size, minSize ?? minSizeOf(token));
|
||||||
|
const family = opts.family ?? resolveFace(token.face);
|
||||||
|
let size = token.size;
|
||||||
|
for (;;) {
|
||||||
|
const w = measureToken(m, token, text, { ...opts, size, family });
|
||||||
|
if (w <= width + 1e-6) return { size, fits: true, width: w };
|
||||||
|
if (size <= floor + 1e-9) return { size, fits: false, width: w };
|
||||||
|
size = Math.max(floor, Math.round((size - STEP) * 100) / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The longest prefix of `text` that, followed by an ellipsis, fits `width` at `size`. */
|
||||||
|
function truncateToFit(token: TypeToken, text: string, width: number, size: number, opts: { weight?: number; italic?: boolean; family: string }): string {
|
||||||
|
const m = getMeasurer();
|
||||||
|
const chars = Array.from(text);
|
||||||
|
const widthOf = (n: number) => measureToken(m, token, chars.slice(0, n).join("").trimEnd() + "…", { ...opts, size });
|
||||||
|
let lo = 0;
|
||||||
|
let hi = chars.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = Math.ceil((lo + hi) / 2);
|
||||||
|
if (widthOf(mid) <= width + 1e-6) lo = mid;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return chars.slice(0, lo).join("").trimEnd() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-line text that shrinks to fit. If it still does not fit at `minSize` it is cut to the longest
|
||||||
|
* prefix plus an ellipsis at that size. This is done here and not with maxLines: textkit never breaks
|
||||||
|
* inside a word, so a single over-long token stays on its one line, overflows the box and gets no
|
||||||
|
* ellipsis (verified: a 60-character e-mail in a 150 pt box ran to 257 pt). maxLines stays on as a
|
||||||
|
* backstop for multi-word text.
|
||||||
|
*/
|
||||||
|
export function FitText({ role, width, minSize, children, onFit, ...rest }: FitTextProps) {
|
||||||
|
const token = roleToken(role);
|
||||||
|
const family = resolveFace(token.face, rest.faceOverrides);
|
||||||
|
const info = fitSize(token, children, width, minSize, { weight: rest.weight, italic: rest.italic, family });
|
||||||
|
onFit?.(info);
|
||||||
|
// Leading stays the role's: only the glyph size shrinks.
|
||||||
|
const sized: TypeToken = { ...token, size: info.size };
|
||||||
|
const text = info.fits ? children : truncateToFit(token, children, width, info.size, { weight: rest.weight, italic: rest.italic, family });
|
||||||
|
return (
|
||||||
|
<Txt {...rest} role={sized} width={width} maxLines={info.fits ? undefined : 1}>
|
||||||
|
{text}
|
||||||
|
</Txt>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { chunkIdentifier } from "../model/text";
|
||||||
|
import { useAvailableWidth } from "./AvailableWidth";
|
||||||
|
import { fitSize, type FitInfo } from "./FitText";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface IdTextInfo extends FitInfo {
|
||||||
|
mode: "single" | "chunked";
|
||||||
|
chunks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLines" | "width" | "grow"> {
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
children: string;
|
||||||
|
/** Width to fit; defaults to the available width of the enclosing cell. */
|
||||||
|
width?: number;
|
||||||
|
minSize?: number;
|
||||||
|
/** For tests and audits only. */
|
||||||
|
onFit?: (info: IdTextInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text for identifiers (e-mail, URL, GSTIN, phone). textkit never breaks inside a word, so a long
|
||||||
|
* value would overflow silently. This shrinks it to the role's minimum size; if it is still too wide
|
||||||
|
* it is split at "@ . / - _" into a flex-wrap row of adjacent Text chunks (no gaps, so extraction
|
||||||
|
* joins them back into the original string).
|
||||||
|
*/
|
||||||
|
export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTextProps) {
|
||||||
|
const avail = useAvailableWidth();
|
||||||
|
const target = width ?? avail;
|
||||||
|
const token = roleToken(role);
|
||||||
|
const fit = fitSize(token, children, target, minSize, { weight: rest.weight, italic: rest.italic });
|
||||||
|
|
||||||
|
if (fit.fits) {
|
||||||
|
onFit?.({ ...fit, mode: "single", chunks: [children] });
|
||||||
|
return <Txt {...rest} role={{ ...token, size: fit.size }}>{children}</Txt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sized: TypeToken = { ...token, size: fit.size };
|
||||||
|
const m = getMeasurer();
|
||||||
|
const family = resolveFace(token.face, rest.faceOverrides);
|
||||||
|
const widthOf = (s: string) =>
|
||||||
|
measureToken(m, sized, s, { weight: rest.weight, italic: rest.italic, family });
|
||||||
|
let chunks = chunkIdentifier(children, 1);
|
||||||
|
for (let max = children.length; max >= 1; max--) {
|
||||||
|
const candidate = chunkIdentifier(children, max);
|
||||||
|
if (candidate.every((c) => widthOf(c) <= target + 1e-6)) {
|
||||||
|
chunks = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onFit?.({ ...fit, mode: "chunked", chunks });
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", width: target, minWidth: target, maxWidth: target } as never}>
|
||||||
|
{chunks.map((c, i) => (
|
||||||
|
<Txt key={i} {...rest} align={undefined} role={sized}>
|
||||||
|
{c}
|
||||||
|
</Txt>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { colsDetailed, type ColSpec } from "../engine/columns";
|
||||||
|
import { PlanError } from "../engine/errors";
|
||||||
|
import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids";
|
||||||
|
import { drawnText, type Measurer } from "../engine/measure";
|
||||||
|
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { AvailableWidthProvider } from "./AvailableWidth";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
import { Cell, Row } from "./Row";
|
||||||
|
import { Txt } from "./Txt";
|
||||||
|
|
||||||
|
/** A cell: one string, or several (one Txt each, stacked), for multi-line values. */
|
||||||
|
export type TableCellValue = string | string[];
|
||||||
|
|
||||||
|
const cellParts = (v: TableCellValue): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
|
||||||
|
|
||||||
|
export interface TableRoles {
|
||||||
|
head: RoleName | TypeToken;
|
||||||
|
body: RoleName | TypeToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableLook {
|
||||||
|
/** Horizontal cell padding, pt per side. Default 0. */
|
||||||
|
padX?: number;
|
||||||
|
/** Vertical cell padding, pt per side. Default 4. */
|
||||||
|
padY?: number;
|
||||||
|
/** Bottom border of each row in pt (counted in the row height). Default 0. */
|
||||||
|
border?: number;
|
||||||
|
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
|
||||||
|
padRight?: number[];
|
||||||
|
faceOverrides?: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemsTablePlanInput extends TableLook {
|
||||||
|
columns: ColSpec[];
|
||||||
|
gap: number;
|
||||||
|
/** Table width in pt (the content width of the page, or of the cell holding the table). */
|
||||||
|
width: number;
|
||||||
|
header: TableCellValue[];
|
||||||
|
rows: TableCellValue[][];
|
||||||
|
measurer: Measurer;
|
||||||
|
roles: TableRoles;
|
||||||
|
/** Height of whatever follows the last row and must stay with it (the totals). */
|
||||||
|
tailH?: number;
|
||||||
|
/** Wrap-area height of one page. With `tailH` it lets the planner prove the tail can ever fit. */
|
||||||
|
contentH?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemsTablePlan {
|
||||||
|
widths: number[];
|
||||||
|
headH: number;
|
||||||
|
rowHeights: number[];
|
||||||
|
firstRowH: number;
|
||||||
|
/** Predicted line count per row (the tallest cell). */
|
||||||
|
rowLines: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } {
|
||||||
|
const t = roleToken(role);
|
||||||
|
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
|
||||||
|
let lines = 0;
|
||||||
|
for (const text of cellParts(value)) {
|
||||||
|
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
|
||||||
|
lines += Math.max(
|
||||||
|
1,
|
||||||
|
m.wrapLines(drawnText(t, text), inner, {
|
||||||
|
face: resolveFace(t.face, look.faceOverrides),
|
||||||
|
weight: t.weight,
|
||||||
|
italic: t.italic ?? false,
|
||||||
|
size: t.size,
|
||||||
|
letterSpacing: (t.tracking ?? 0) * t.size,
|
||||||
|
}).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { lines, height: lines * t.leading };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plans an items table without rendering: column widths, the wrapped height of the header and of
|
||||||
|
* every row. Heights are estimates from the measurer (see Measurer.wrapLines) and feed
|
||||||
|
* TableGuard/BottomSpacer presence values.
|
||||||
|
*
|
||||||
|
* Throws PlanError("tail-too-tall") when the totals tail could never share a page with the header and
|
||||||
|
* the last row: `tailH <= contentH - headH - lastRowH`.
|
||||||
|
*/
|
||||||
|
export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
|
||||||
|
const { columns, gap, width, header, rows, measurer, roles, tailH, contentH } = input;
|
||||||
|
const padX = input.padX ?? 0;
|
||||||
|
const padY = input.padY ?? 4;
|
||||||
|
const border = input.border ?? 0;
|
||||||
|
if (header.length !== columns.length) throw new PlanError(`${header.length} header cells for ${columns.length} columns`, "invalid-plan");
|
||||||
|
if (rows.length === 0) throw new PlanError("an items table needs at least one row", "invalid-plan");
|
||||||
|
for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan");
|
||||||
|
|
||||||
|
const { widths } = colsDetailed(width, columns, gap);
|
||||||
|
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
|
||||||
|
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => {
|
||||||
|
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input));
|
||||||
|
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border };
|
||||||
|
};
|
||||||
|
|
||||||
|
const headH = heightOf(roles.head, header).height;
|
||||||
|
const measured = rows.map((r) => heightOf(roles.body, r));
|
||||||
|
const rowHeights = measured.map((r) => r.height);
|
||||||
|
|
||||||
|
if (tailH !== undefined && contentH !== undefined) {
|
||||||
|
const lastRowH = rowHeights[rowHeights.length - 1];
|
||||||
|
if (tailH > contentH - headH - lastRowH + 1e-6) {
|
||||||
|
throw new PlanError(
|
||||||
|
`totals tail ${tailH} pt cannot share a ${contentH} pt page with the header (${headH}) and the last row (${lastRowH})`,
|
||||||
|
"tail-too-tall",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { widths, headH, rowHeights, firstRowH: rowHeights[0], rowLines: measured.map((r) => r.lines) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableRowsProps extends TableLook {
|
||||||
|
columns: ColSpec[];
|
||||||
|
gap: number;
|
||||||
|
width: number;
|
||||||
|
header: TableCellValue[];
|
||||||
|
rows: TableCellValue[][];
|
||||||
|
roles: TableRoles;
|
||||||
|
/** Per column; default left. */
|
||||||
|
align?: Array<"left" | "right" | "center">;
|
||||||
|
/** Passed as minPresenceAhead on the last row so the totals never start a page without an item row. */
|
||||||
|
tailH?: number;
|
||||||
|
/** Content after the last row, inside the table container (the totals). */
|
||||||
|
tail?: ReactNode;
|
||||||
|
headerStyle?: Record<string, unknown>;
|
||||||
|
rowStyle?: Record<string, unknown>;
|
||||||
|
borderColor?: string;
|
||||||
|
/** Text colour of the header cells. */
|
||||||
|
headColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header (a `fixed` first child, so it repeats on every page the table spans) followed by one
|
||||||
|
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
|
||||||
|
* the repeated header is positioned against the container's top.
|
||||||
|
*/
|
||||||
|
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, ...look }: TableRowsProps) {
|
||||||
|
const padX = look.padX ?? 0;
|
||||||
|
const padY = look.padY ?? 4;
|
||||||
|
const border = look.border ?? 0;
|
||||||
|
const { widths } = colsDetailed(width, columns, gap);
|
||||||
|
const cellStyle = (extra?: Record<string, unknown>) => ({
|
||||||
|
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) =>
|
||||||
|
values.map((v, i) => (
|
||||||
|
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
|
||||||
|
{cellParts(v).map((part, li) => (
|
||||||
|
<Txt key={li} role={role} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||||
|
{part}
|
||||||
|
</Txt>
|
||||||
|
))}
|
||||||
|
</Cell>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvailableWidthProvider width={width}>
|
||||||
|
<View>
|
||||||
|
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||||
|
{cells(roles.head, header, headColor)}
|
||||||
|
</Row>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<Row
|
||||||
|
key={i}
|
||||||
|
id={`${TABLE_ROW_PREFIX}${i}`}
|
||||||
|
widths={columns}
|
||||||
|
gap={gap}
|
||||||
|
width={width}
|
||||||
|
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
|
||||||
|
style={cellStyle(rowStyle)}
|
||||||
|
>
|
||||||
|
{cells(roles.body, r)}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
{tail}
|
||||||
|
</View>
|
||||||
|
</AvailableWidthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { FILL } from "../engine/columns";
|
||||||
|
import { chromeHeight } from "../engine/metrics";
|
||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import type { PageFrame } from "../engine/geometry";
|
||||||
|
import { BAND_ID, DECOR_ID, PAGE_LABEL_ID } from "../engine/ids";
|
||||||
|
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { PlanError } from "../engine/errors";
|
||||||
|
import type { DecorItem } from "./FirstPageHeader";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
import { FitText } from "./FitText";
|
||||||
|
import { RenderTxt } from "./RenderTxt";
|
||||||
|
|
||||||
|
export interface PageChromeProps {
|
||||||
|
frame: PageFrame;
|
||||||
|
/** Left footer text (e.g. the invoice number); omit for none. */
|
||||||
|
footerLeft?: string;
|
||||||
|
/**
|
||||||
|
* Pins the left text to this width (pt) and fits it to one line (shrunk, then cut with an ellipsis).
|
||||||
|
* A render-prop Text cannot be clamped, so a long supplier name there would wrap and push the footer
|
||||||
|
* up into the content. Without it the left text takes the remaining width.
|
||||||
|
*/
|
||||||
|
footerLeftWidth?: number;
|
||||||
|
/** Centre text between the left text and the page label. */
|
||||||
|
footerCenter?: string;
|
||||||
|
/** Show "Page n of N" at the right of the footer. Default true. */
|
||||||
|
pageLabel?: boolean;
|
||||||
|
/** Footer and band type; render-prop roles (no leading). Default "footer". */
|
||||||
|
role?: RoleName | TypeToken;
|
||||||
|
color?: string;
|
||||||
|
/** Continuation band for pages 2+ (shown only when pageNumber > 1). */
|
||||||
|
band?: { left?: string; right?: string };
|
||||||
|
/** Page-space top of the band. Default: the top of the reserve, pad.top - continuationReserve (>= 0). */
|
||||||
|
bandTop?: number;
|
||||||
|
/** Every-page decor in page space. */
|
||||||
|
decor?: DecorItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FitText is static text and needs a leading; the footer role has none (render-prop text), so the
|
||||||
|
* fitted left text borrows 1.4x the size, which stays within the auto-height line that chromeHeight
|
||||||
|
* reserves for the same role.
|
||||||
|
*/
|
||||||
|
function fineFooter(role: RoleName | TypeToken): TypeToken {
|
||||||
|
const t = roleToken(role);
|
||||||
|
return { ...t, leading: Math.round(t.size * 1.4 * 10) / 10 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Page 3 of 5"; before the total is known (first layout pass) just "Page 3". */
|
||||||
|
export function pageLabelText(pageNumber: number, totalPages: number | undefined): string {
|
||||||
|
return typeof totalPages === "number" && totalPages > 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed chrome for every page. Put it AFTER the flow as the last child of the Page: with the legacy
|
||||||
|
* paginator a fixed node placed after the flow still repeats on every page.
|
||||||
|
*
|
||||||
|
* Everything is a page-level absolute anchored to the page edges, so none of it takes part in the flow:
|
||||||
|
* - footer: a row in the bottom margin, between the content bottom (page height - pad.bottom) and the
|
||||||
|
* physical margin. `frame.footerReserve` must cover chromeHeight(role); both texts are render-prop
|
||||||
|
* Text in an AUTO-height box (a fixed height smaller than the line draws nothing).
|
||||||
|
* - continuation band: only on pages 2+. On page 1 its render prop returns "" (0 lines, expected).
|
||||||
|
* - decor: fixed absolute boxes in page space, flagged with DECOR_ID.
|
||||||
|
*
|
||||||
|
* The page label has a pinned width taken from the widest label ("Page 99 of 99"), never from the
|
||||||
|
* current pass, because `totalPages` is undefined on the first pass and `subPageNumber` in the split pass.
|
||||||
|
*/
|
||||||
|
export function PageChrome({
|
||||||
|
frame,
|
||||||
|
footerLeft,
|
||||||
|
footerLeftWidth,
|
||||||
|
footerCenter,
|
||||||
|
pageLabel = true,
|
||||||
|
role = "footer",
|
||||||
|
color,
|
||||||
|
band,
|
||||||
|
bandTop,
|
||||||
|
decor = [],
|
||||||
|
}: PageChromeProps) {
|
||||||
|
const { pad } = frame;
|
||||||
|
const need = chromeHeight(role);
|
||||||
|
const showFooter = pageLabel || footerLeft !== undefined || footerCenter !== undefined;
|
||||||
|
if (showFooter && frame.footerReserve < need) {
|
||||||
|
throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve");
|
||||||
|
}
|
||||||
|
const labelWidth = Math.ceil(measureToken(getMeasurer(), role, "Page 99 of 99")) + 2;
|
||||||
|
const marginBottom = pad.bottom - frame.footerReserve;
|
||||||
|
const bandRightWidth = band?.right !== undefined ? Math.ceil(measureToken(getMeasurer(), role, band.right)) + 2 : 0;
|
||||||
|
const top = bandTop ?? Math.max(0, pad.top - frame.continuationReserve);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{decor.map((d, i) => (
|
||||||
|
<View
|
||||||
|
key={`decor-${i}`}
|
||||||
|
id={DECOR_ID}
|
||||||
|
style={{ position: "absolute", left: d.x, top: d.y, width: d.width, height: d.height }}
|
||||||
|
{...pdfFlags({ fixed: true })}
|
||||||
|
>
|
||||||
|
{d.children}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{band && (band.left !== undefined || band.right !== undefined) && (
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", top, left: pad.left, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||||
|
{...pdfFlags({ fixed: true })}
|
||||||
|
>
|
||||||
|
{band.left !== undefined && (
|
||||||
|
<RenderTxt id={BAND_ID} role={role} color={color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
|
||||||
|
)}
|
||||||
|
{band.right !== undefined && (
|
||||||
|
<RenderTxt
|
||||||
|
id={BAND_ID}
|
||||||
|
role={role}
|
||||||
|
color={color}
|
||||||
|
align="right"
|
||||||
|
width={bandRightWidth}
|
||||||
|
render={({ pageNumber }) => (pageNumber > 1 ? (band.right ?? "") : "")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFooter && (
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||||
|
{...pdfFlags({ fixed: true })}
|
||||||
|
>
|
||||||
|
{footerLeft !== undefined &&
|
||||||
|
(footerLeftWidth !== undefined ? (
|
||||||
|
<FitText role={fineFooter(role)} width={footerLeftWidth} color={color}>
|
||||||
|
{footerLeft}
|
||||||
|
</FitText>
|
||||||
|
) : (
|
||||||
|
<RenderTxt role={role} color={color} grow render={() => footerLeft} />
|
||||||
|
))}
|
||||||
|
{footerCenter !== undefined && <RenderTxt role={role} color={color} align="center" grow render={() => footerCenter} />}
|
||||||
|
{footerCenter === undefined && footerLeftWidth !== undefined && <View style={FILL as never} />}
|
||||||
|
{pageLabel && (
|
||||||
|
<RenderTxt
|
||||||
|
id={PAGE_LABEL_ID}
|
||||||
|
role={role}
|
||||||
|
color={color}
|
||||||
|
align="right"
|
||||||
|
width={labelWidth}
|
||||||
|
render={({ pageNumber, totalPages }) => pageLabelText(pageNumber, totalPages)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Text } from "@react-pdf/renderer";
|
||||||
|
import { FILL, pinned } from "../engine/columns";
|
||||||
|
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||||
|
import { assertNoLineHeight, baseTextStyle, type TxtStyleProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput {
|
||||||
|
/** Receives { pageNumber, totalPages, subPageNumber, ... } from react-pdf. */
|
||||||
|
render: (info: { pageNumber: number; totalPages: number; subPageNumber: number; subPageTotalPages: number }) => string;
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
/** react-pdf copies the id into the layout tree (the audit finds chrome nodes by it). */
|
||||||
|
id?: string;
|
||||||
|
width?: number;
|
||||||
|
grow?: boolean;
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text drawn from a render prop (page numbers, continuation band). It sets NO lineHeight, and no
|
||||||
|
* ancestor may set one either: react-pdf multiplies a render-prop lineHeight again on every relayout
|
||||||
|
* ('10pt' ends up as 5120 and nothing draws). The box is auto-height; reserve its space from font
|
||||||
|
* metrics, never a fixed height. No maxLines: a clamped render-prop Text draws nothing.
|
||||||
|
* `totalPages` is undefined on the first layout pass, so the render function must cope with that.
|
||||||
|
*/
|
||||||
|
export function RenderTxt({ id, role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) {
|
||||||
|
const token = roleToken(role);
|
||||||
|
assertNoLineHeight(style, "RenderTxt");
|
||||||
|
const merged = {
|
||||||
|
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
|
||||||
|
...(width !== undefined ? pinned(width) : {}),
|
||||||
|
...(grow ? FILL : {}),
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
return <Text {...(id !== undefined ? { id } : {})} style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { Children, type ReactNode } from "react";
|
||||||
|
import { colsDetailed, FILL, pinned, type ColSpec } from "../engine/columns";
|
||||||
|
import { AvailableWidthProvider, useAvailableWidth } from "./AvailableWidth";
|
||||||
|
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||||
|
|
||||||
|
export interface RowProps extends PdfFlagsInput {
|
||||||
|
/** Copied into the layout tree (audits find rows by it). */
|
||||||
|
id?: string;
|
||||||
|
/** One spec per child, in order. */
|
||||||
|
widths: ColSpec[];
|
||||||
|
gap?: number;
|
||||||
|
/** Stack the children as a column when the row is narrower than this. */
|
||||||
|
stackBelow?: number;
|
||||||
|
/** Cross-axis alignment of the cells; never flex-end around multi-line text. */
|
||||||
|
align?: "flex-start" | "center" | "stretch";
|
||||||
|
/** Overrides the width from AvailableWidth. */
|
||||||
|
width?: number;
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flex row, pinned to its total width (a row inside a flex-start parent would otherwise shrink to its content and leave its fill cell empty), whose children each sit in a cell of explicit width: fixed and fractional cells are pinned
|
||||||
|
* (width = minWidth = maxWidth) and the fill cell uses {flexGrow: 1, flexBasis: 0}. Descendants read the
|
||||||
|
* cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is
|
||||||
|
* narrower than `stackBelow`), the children are stacked at full width instead.
|
||||||
|
*/
|
||||||
|
export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) {
|
||||||
|
const inherited = useAvailableWidth();
|
||||||
|
const total = width ?? inherited;
|
||||||
|
const kids = Children.toArray(children);
|
||||||
|
if (kids.length !== widths.length) throw new Error(`Row: ${kids.length} children for ${widths.length} column specs`);
|
||||||
|
|
||||||
|
const { widths: w, ok } = colsDetailed(total, widths, gap);
|
||||||
|
const stacked = !ok || (stackBelow !== undefined && total < stackBelow);
|
||||||
|
const flags = { ...(id !== undefined ? { id } : {}), ...pdfFlags({ fixed, wrap, minPresenceAhead }) };
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "column", alignItems: "flex-start", ...pinned(total), ...style } as never} {...flags}>
|
||||||
|
{kids.map((child, i) => (
|
||||||
|
<View key={i} style={{ ...pinned(total), alignItems: "flex-start", ...(gap && i > 0 ? { marginTop: gap } : {}) } as never}>
|
||||||
|
<AvailableWidthProvider width={total}>{child}</AvailableWidthProvider>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: align, ...pinned(total), ...style } as never} {...flags}>
|
||||||
|
{kids.map((child, i) => {
|
||||||
|
const isFill = "fill" in widths[i];
|
||||||
|
const box = isFill ? FILL : pinned(w[i]);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{ ...box, alignItems: "flex-start", ...(i > 0 && gap ? { marginLeft: gap } : {}) } as never}
|
||||||
|
>
|
||||||
|
<AvailableWidthProvider width={w[i]}>{child}</AvailableWidthProvider>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellProps {
|
||||||
|
/** Padding in pt: one number for all sides, or per side. */
|
||||||
|
padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
|
||||||
|
borderLeft?: number;
|
||||||
|
borderRight?: number;
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A padded/bordered box inside a Row column. It fills the column's width and republishes the content
|
||||||
|
* width (column width minus its own padding and border) to its descendants.
|
||||||
|
*/
|
||||||
|
export function Cell({ padding = 0, borderLeft = 0, borderRight = 0, style, children }: CellProps) {
|
||||||
|
const full = useAvailableWidth();
|
||||||
|
const p = typeof padding === "number" ? { top: padding, right: padding, bottom: padding, left: padding } : padding;
|
||||||
|
const inner = Math.max(0, full - (p.left ?? 0) - (p.right ?? 0) - borderLeft - borderRight);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
...pinned(full),
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingTop: p.top ?? 0,
|
||||||
|
paddingRight: p.right ?? 0,
|
||||||
|
paddingBottom: p.bottom ?? 0,
|
||||||
|
paddingLeft: p.left ?? 0,
|
||||||
|
...(borderLeft ? { borderLeftWidth: borderLeft } : {}),
|
||||||
|
...(borderRight ? { borderRightWidth: borderRight } : {}),
|
||||||
|
...style,
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AvailableWidthProvider width={inner}>{children}</AvailableWidthProvider>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import { resolveFace, roleToken } from "../fonts/roles";
|
||||||
|
import { useAvailableWidth } from "./AvailableWidth";
|
||||||
|
import { IdText } from "./IdText";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface SafeTxtProps extends Omit<TxtProps, "children" | "width" | "maxLines" | "grow"> {
|
||||||
|
children: string;
|
||||||
|
/** Width of the box; defaults to the available width of the enclosing cell. */
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free text (addresses, names, notes) that cannot overflow: normal text wraps at spaces in a box of the
|
||||||
|
* cell's width; a value with a single token wider than the cell (a long URL or an unbroken string) goes
|
||||||
|
* through IdText, which shrinks it or splits it at "@ . / - _". textkit never breaks inside a word,
|
||||||
|
* so without this such a token would run out of its box.
|
||||||
|
*/
|
||||||
|
export function SafeTxt({ children, width, align, ...rest }: SafeTxtProps) {
|
||||||
|
const avail = useAvailableWidth();
|
||||||
|
const w = width ?? avail;
|
||||||
|
const token = roleToken(rest.role);
|
||||||
|
const m = getMeasurer();
|
||||||
|
const family = resolveFace(token.face, rest.faceOverrides);
|
||||||
|
const widest = Math.max(
|
||||||
|
0,
|
||||||
|
...children.split(" ").map((word) => measureToken(m, token, word, { weight: rest.weight, italic: rest.italic, family })),
|
||||||
|
);
|
||||||
|
if (widest > w + 1e-6) {
|
||||||
|
return (
|
||||||
|
<IdText {...rest} width={w}>
|
||||||
|
{children}
|
||||||
|
</IdText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Txt {...rest} align={align} width={w}>
|
||||||
|
{children}
|
||||||
|
</Txt>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presence to ask for in front of a table: the header plus the first row, with the first row capped at
|
||||||
|
* three body lines so an oversize first row cannot push the table to a new page every time.
|
||||||
|
*/
|
||||||
|
export function guardPresence(headH: number, firstRowH: number, bodyLeading: number): number {
|
||||||
|
return headH + Math.min(firstRowH, 3 * bodyLeading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero-height sentinel placed immediately before a table. react-pdf's `minPresenceAhead` measures from
|
||||||
|
* the element's bottom, so this moves itself (and with it the whole table) to the next page unless the
|
||||||
|
* header and the first rows fit below it. Without it the repeated header can stay alone at a page foot.
|
||||||
|
*/
|
||||||
|
export function TableGuard({ minPresenceAhead }: { minPresenceAhead: number }) {
|
||||||
|
return <View style={{ height: 0 }} {...pdfFlags({ minPresenceAhead })} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { roleToken } from "../fonts/roles";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface TrackedProps extends Omit<TxtProps, "align"> {
|
||||||
|
align: "right" | "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* textkit adds letterSpacing after every glyph, the last one included, so right- and centre-aligned
|
||||||
|
* tracked text sits one tracking step too far left. This cancels it with a negative margin: the full
|
||||||
|
* step on the right edge for `right`, half a step each side for `center` (shifting the box right by
|
||||||
|
* half a step).
|
||||||
|
*
|
||||||
|
* HAZARD: the Text box now overhangs its parent by up to one tracking step. Overflow and safe-zone
|
||||||
|
* audits must measure ink (glyph extents), not boxes, or they flag it.
|
||||||
|
*/
|
||||||
|
export function Tracked({ align, style, ...rest }: TrackedProps) {
|
||||||
|
const token = roleToken(rest.role);
|
||||||
|
const step = (token.tracking ?? 0) * token.size;
|
||||||
|
const margin =
|
||||||
|
align === "right"
|
||||||
|
? { marginRight: -step }
|
||||||
|
: { marginLeft: step / 2, marginRight: -step / 2 };
|
||||||
|
return <Txt {...rest} align={align} style={{ ...margin, ...style }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Text } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { FILL, pinned } from "../engine/columns";
|
||||||
|
import { stack } from "../fonts/register";
|
||||||
|
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||||
|
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
|
||||||
|
export type TxtChild = string | number | Array<string | number>;
|
||||||
|
type Align = "left" | "right" | "center" | "justify";
|
||||||
|
|
||||||
|
export interface TxtStyleProps {
|
||||||
|
color?: string;
|
||||||
|
align?: Align;
|
||||||
|
weight?: TypeToken["weight"];
|
||||||
|
italic?: boolean;
|
||||||
|
/** Font family overrides (e.g. a user-imported font replacing a template face). */
|
||||||
|
faceOverrides?: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TxtProps extends TxtStyleProps, PdfFlagsInput {
|
||||||
|
/** react-pdf copies the id into the layout tree (audits and contract tests find nodes by it). */
|
||||||
|
id?: string;
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
/** Clamp to this many lines and end with an ellipsis. */
|
||||||
|
maxLines?: number;
|
||||||
|
/** Pin the box to this width in pt (width = minWidth = maxWidth). */
|
||||||
|
width?: number;
|
||||||
|
/** Take the remaining row width ({flexGrow: 1, flexBasis: 0}). */
|
||||||
|
grow?: boolean;
|
||||||
|
/** Extra style. Must not contain lineHeight: leading comes from the role. */
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: TxtChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Style fields shared by Txt and RenderTxt: everything except lineHeight. */
|
||||||
|
export function baseTextStyle(token: TypeToken, o: TxtStyleProps) {
|
||||||
|
const size = token.size;
|
||||||
|
const weight = o.weight ?? token.weight;
|
||||||
|
return {
|
||||||
|
fontFamily: stack(resolveFace(token.face, o.faceOverrides)),
|
||||||
|
fontSize: size,
|
||||||
|
fontWeight: weight,
|
||||||
|
fontStyle: (o.italic ?? token.italic) ? "italic" : "normal",
|
||||||
|
...(token.tracking ? { letterSpacing: token.tracking * size } : {}),
|
||||||
|
...(token.caps ? { textTransform: "uppercase" } : {}),
|
||||||
|
...(o.color ? { color: o.color } : {}),
|
||||||
|
...(o.align ? { textAlign: o.align } : {}),
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNoLineHeight(style: Record<string, unknown> | undefined, who: string): void {
|
||||||
|
if (style && "lineHeight" in style && process.env.NODE_ENV !== "production") {
|
||||||
|
throw new Error(`${who}: style must not contain lineHeight; the role sets the leading`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static text. Always sets family, size, weight, style and an absolute `lineHeight: "Npt"` on the
|
||||||
|
* same node; never put a lineHeight on an ancestor View.
|
||||||
|
*/
|
||||||
|
export function Txt({ id, role, color, align, weight, italic, maxLines, width, grow, style, faceOverrides, children, fixed, wrap, minPresenceAhead }: TxtProps) {
|
||||||
|
const token = roleToken(role);
|
||||||
|
if (token.leading === undefined) throw new Error("Txt needs a role with a leading; use RenderTxt for render-prop text");
|
||||||
|
assertNoLineHeight(style, "Txt");
|
||||||
|
const merged = {
|
||||||
|
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
|
||||||
|
lineHeight: `${token.leading}pt`,
|
||||||
|
...(width !== undefined ? pinned(width) : {}),
|
||||||
|
...(grow ? FILL : {}),
|
||||||
|
...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}),
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
return <Text {...(id !== undefined ? { id } : {})} style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { createElement as h, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Document, Page, View } from "@react-pdf/renderer";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stack } from "../fonts/register";
|
||||||
|
import { TYPE_ROLES } from "../fonts/roles";
|
||||||
|
import { assertNoOverlap } from "../testing/asserts";
|
||||||
|
import { renderPdfText } from "../testing/pdfText";
|
||||||
|
import { setupPdfTest } from "../testing/setup";
|
||||||
|
import { AvailableWidthProvider } from "./AvailableWidth";
|
||||||
|
import { FitText, type FitInfo } from "./FitText";
|
||||||
|
import { IdText, type IdTextInfo } from "./IdText";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
import { RenderTxt } from "./RenderTxt";
|
||||||
|
import { Cell, Row } from "./Row";
|
||||||
|
import { Tracked } from "./Tracked";
|
||||||
|
import { Txt } from "./Txt";
|
||||||
|
|
||||||
|
setupPdfTest();
|
||||||
|
|
||||||
|
const PAD = 20;
|
||||||
|
const PAGE_W = 400;
|
||||||
|
|
||||||
|
/** A page whose content box is `width` pt wide and published through AvailableWidth. */
|
||||||
|
function frame(children: ReactNode, width = PAGE_W - 2 * PAD): ReactElement {
|
||||||
|
return h(
|
||||||
|
Document,
|
||||||
|
null,
|
||||||
|
h(
|
||||||
|
Page,
|
||||||
|
{ size: { width: PAGE_W, height: 600 }, style: { padding: PAD } },
|
||||||
|
h(AvailableWidthProvider, { width }, children),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TxtLike = { props: { style: Record<string, unknown> } };
|
||||||
|
const styleOf = (el: unknown) => (el as TxtLike).props.style;
|
||||||
|
|
||||||
|
describe("pdfFlags", () => {
|
||||||
|
it("spreads only meaningful keys", () => {
|
||||||
|
expect(pdfFlags()).toEqual({});
|
||||||
|
expect(pdfFlags({ fixed: false, wrap: undefined, minPresenceAhead: undefined })).toEqual({});
|
||||||
|
expect(pdfFlags({ fixed: true })).toEqual({ fixed: true });
|
||||||
|
expect(pdfFlags({ wrap: true })).toEqual({});
|
||||||
|
expect(pdfFlags({ wrap: false })).toEqual({ wrap: false });
|
||||||
|
expect(pdfFlags({ minPresenceAhead: 0 })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: -3 })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: NaN })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: Infinity })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: 12.5 })).toEqual({ minPresenceAhead: 12.5 });
|
||||||
|
expect(pdfFlags({ fixed: true, wrap: false, minPresenceAhead: 9 })).toEqual({
|
||||||
|
fixed: true,
|
||||||
|
wrap: false,
|
||||||
|
minPresenceAhead: 9,
|
||||||
|
});
|
||||||
|
// Keys must be absent, not undefined: react-pdf checks presence.
|
||||||
|
expect(Object.keys(pdfFlags({ fixed: false }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches the element only when set", () => {
|
||||||
|
const off = Txt({ role: "body", children: "x", fixed: false }) as unknown as { props: Record<string, unknown> };
|
||||||
|
expect("fixed" in off.props).toBe(false);
|
||||||
|
const on = Txt({ role: "body", children: "x", fixed: true }) as unknown as { props: Record<string, unknown> };
|
||||||
|
expect(on.props.fixed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Txt", () => {
|
||||||
|
it("sets lineHeight in pt, the family stack and the role metrics", () => {
|
||||||
|
const s = styleOf(Txt({ role: "body", children: "x" }));
|
||||||
|
expect(s.lineHeight).toBe("13.8pt");
|
||||||
|
expect(s.fontFamily).toEqual(stack("IBM Plex Sans"));
|
||||||
|
expect(s.fontSize).toBe(9.5);
|
||||||
|
expect(s.fontWeight).toBe(400);
|
||||||
|
expect(s.fontStyle).toBe("normal");
|
||||||
|
expect("letterSpacing" in s).toBe(false);
|
||||||
|
expect("textTransform" in s).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies tracking, caps and overrides", () => {
|
||||||
|
const s = styleOf(Txt({ role: "title", weight: 700, italic: true, color: "#111", align: "right", children: "x" }));
|
||||||
|
expect(s.letterSpacing).toBeCloseTo(1.5, 10);
|
||||||
|
expect(s.textTransform).toBe("uppercase");
|
||||||
|
expect(s.fontWeight).toBe(700);
|
||||||
|
expect(s.fontStyle).toBe("italic");
|
||||||
|
expect(s.color).toBe("#111");
|
||||||
|
expect(s.textAlign).toBe("right");
|
||||||
|
expect(s.lineHeight).toBe("22pt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins width, grows and clamps lines", () => {
|
||||||
|
const pinnedStyle = styleOf(Txt({ role: "body", width: 80, children: "x" }));
|
||||||
|
expect([pinnedStyle.width, pinnedStyle.minWidth, pinnedStyle.maxWidth]).toEqual([80, 80, 80]);
|
||||||
|
const grown = styleOf(Txt({ role: "body", grow: true, children: "x" }));
|
||||||
|
expect([grown.flexGrow, grown.flexBasis]).toEqual([1, 0]);
|
||||||
|
const clamped = styleOf(Txt({ role: "body", maxLines: 2, children: "x" }));
|
||||||
|
expect([clamped.maxLines, clamped.textOverflow]).toEqual([2, "ellipsis"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a lineHeight in the extra style and on a role without leading", () => {
|
||||||
|
expect(() => Txt({ role: "body", style: { lineHeight: 2 }, children: "x" })).toThrow(/lineHeight/);
|
||||||
|
expect(() => Txt({ role: "footer", children: "x" })).toThrow(/leading/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every leaded role has a positive leading and footer has none", () => {
|
||||||
|
for (const [name, t] of Object.entries(TYPE_ROLES)) {
|
||||||
|
if (name === "footer") expect("leading" in t).toBe(false);
|
||||||
|
else expect((t as { leading: number }).leading).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RenderTxt", () => {
|
||||||
|
it("never sets lineHeight and rejects one", () => {
|
||||||
|
const el = RenderTxt({ role: "footer", render: () => "x" }) as unknown as {
|
||||||
|
props: { style: Record<string, unknown>; render: unknown };
|
||||||
|
};
|
||||||
|
expect("lineHeight" in el.props.style).toBe(false);
|
||||||
|
expect(typeof el.props.render).toBe("function");
|
||||||
|
expect(() => RenderTxt({ role: "footer", render: () => "x", style: { lineHeight: 1 } })).toThrow(/lineHeight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws page numbers on every page", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
h(
|
||||||
|
Document,
|
||||||
|
null,
|
||||||
|
h(
|
||||||
|
Page,
|
||||||
|
{ size: { width: 300, height: 120 }, style: { padding: 20 } },
|
||||||
|
h(View, { fixed: true }, h(RenderTxt, { role: "footer", render: ({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages ?? "?"}` })),
|
||||||
|
h(View, { style: { height: 200 } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(pdf.pages.length).toBeGreaterThan(1);
|
||||||
|
pdf.pages.forEach((p, i) => {
|
||||||
|
expect(p.items.map((t) => t.str)).toEqual([`Page ${i + 1} of ${pdf.pages.length}`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Row", () => {
|
||||||
|
const tops = (pdf: Awaited<ReturnType<typeof renderPdfText>>, str: string) =>
|
||||||
|
pdf.pages[0].items.find((t) => t.str.trim() === str)!;
|
||||||
|
|
||||||
|
it("lays out side by side when the fill column keeps its minimum", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
h(Row, { widths: [{ fixed: 100 }, { fill: true, min: 100 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const l = tops(pdf, "Left");
|
||||||
|
const r = tops(pdf, "Right");
|
||||||
|
expect(l.x0).toBeCloseTo(PAD, 1);
|
||||||
|
expect(r.x0).toBeCloseTo(PAD + 110, 1);
|
||||||
|
expect(Math.abs(l.top - r.top)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks when the fill column would drop under its min", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
h(Row, { widths: [{ fixed: 250 }, { fill: true, min: 160 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const l = tops(pdf, "Left");
|
||||||
|
const r = tops(pdf, "Right");
|
||||||
|
expect(r.x0).toBeCloseTo(PAD, 1);
|
||||||
|
expect(r.top).toBeGreaterThan(l.bottom - 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks below stackBelow", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
h(Row, { widths: [{ fixed: 50 }, { fill: true }], stackBelow: 500 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(tops(pdf, "Right").top).toBeGreaterThan(tops(pdf, "Left").bottom - 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a children/spec mismatch", async () => {
|
||||||
|
await expect(
|
||||||
|
renderPdfText(frame(<Row widths={[{ fixed: 10 }, { fill: true }]}><Txt role="body">x</Txt></Row>)),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a 300-character address inside its cell and clear of its neighbours", async () => {
|
||||||
|
const address = Array.from({ length: 30 }, (_, i) => `Block${i} Road`).join(", ").slice(0, 300);
|
||||||
|
expect(address.length).toBe(300);
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<Row widths={[{ fill: true, min: 100 }, { fixed: 120 }]} gap={16}>
|
||||||
|
<Cell padding={{ right: 6 }}>
|
||||||
|
<Txt role="body">{address}</Txt>
|
||||||
|
</Cell>
|
||||||
|
<Txt role="body">INVOICE No. 42</Txt>
|
||||||
|
</Row>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assertNoOverlap(pdf.pages);
|
||||||
|
const fillWidth = PAGE_W - 2 * PAD - 120 - 16;
|
||||||
|
const cellRight = PAD + fillWidth - 6;
|
||||||
|
const items = pdf.pages[0].items;
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it.str.includes("INVOICE")) expect(it.x1, it.str).toBeLessThanOrEqual(cellRight + 0.5);
|
||||||
|
}
|
||||||
|
expect(items.find((t) => t.str.includes("INVOICE"))!.x0).toBeGreaterThanOrEqual(PAD + fillWidth + 16 - 0.5);
|
||||||
|
expect(items.length).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FitText and IdText", () => {
|
||||||
|
const EMAIL60 = "Sample.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
|
||||||
|
|
||||||
|
it("shrinks a medium e-mail to fit and keeps it on one line", async () => {
|
||||||
|
const email = "vendor.name@examplestudios.co.in";
|
||||||
|
let info!: FitInfo;
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<FitText role="body" width={215} onFit={(i) => (info = i)}>
|
||||||
|
{email}
|
||||||
|
</FitText>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.fits).toBe(true);
|
||||||
|
expect(info.size).toBeLessThan(9.5);
|
||||||
|
expect(info.size).toBeGreaterThanOrEqual(8.5);
|
||||||
|
const items = pdf.pages[0].items;
|
||||||
|
expect(items.map((t) => t.str).join("")).toBe(email);
|
||||||
|
expect(Math.max(...items.map((t) => t.x1))).toBeLessThanOrEqual(PAD + 215 + 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clips with an ellipsis when it cannot fit at the minimum size", async () => {
|
||||||
|
let info!: FitInfo;
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<FitText role="body" width={150} onFit={(i) => (info = i)}>
|
||||||
|
{EMAIL60}
|
||||||
|
</FitText>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.fits).toBe(false);
|
||||||
|
expect(info.size).toBeCloseTo(8.55, 5);
|
||||||
|
const items = pdf.pages[0].items;
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
expect(items[0].str.endsWith("…")).toBe(true);
|
||||||
|
expect(EMAIL60.startsWith(items[0].str.slice(0, -1))).toBe(true);
|
||||||
|
expect(items[0].x1).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a 60-character e-mail into chunks inside a 150 pt cell", async () => {
|
||||||
|
let info!: IdTextInfo;
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
||||||
|
<IdText role="body" onFit={(i) => (info = i)}>
|
||||||
|
{EMAIL60}
|
||||||
|
</IdText>
|
||||||
|
<Txt role="body">Next</Txt>
|
||||||
|
</Row>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.mode).toBe("chunked");
|
||||||
|
expect(info.chunks.join("")).toBe(EMAIL60);
|
||||||
|
const idItems = pdf.pages[0].items.filter((t) => t.str !== "Next");
|
||||||
|
// Extraction joins the chunks with no inserted characters.
|
||||||
|
expect(idItems.map((t) => t.str).join("")).toBe(EMAIL60);
|
||||||
|
for (const t of idItems) expect(t.x1, t.str).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
||||||
|
assertNoOverlap(pdf.pages);
|
||||||
|
const next = pdf.pages[0].items.find((t) => t.str === "Next")!;
|
||||||
|
expect(next.x0).toBeGreaterThanOrEqual(PAD + 150 - 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an identifier on one line when it already fits", async () => {
|
||||||
|
let info!: IdTextInfo;
|
||||||
|
await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
||||||
|
<IdText role="body" onFit={(i) => (info = i)}>
|
||||||
|
29ABCDE1234F1Z5
|
||||||
|
</IdText>
|
||||||
|
<Txt role="body">x</Txt>
|
||||||
|
</Row>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.mode).toBe("single");
|
||||||
|
expect(info.size).toBe(9.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tracked", () => {
|
||||||
|
const inkRight = async (el: ReactElement) => {
|
||||||
|
const pdf = await renderPdfText(frame(h(View, { style: { width: 200 } }, el)));
|
||||||
|
return pdf.pages[0].items[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
it("puts the right ink edge on the untracked edge", async () => {
|
||||||
|
const plain = await inkRight(h(Txt, { role: { ...TYPE_ROLES.title, tracking: 0 }, align: "right" }, "Invoice"));
|
||||||
|
const tracked = await inkRight(h(Tracked, { role: "title", align: "right" }, "Invoice"));
|
||||||
|
const naive = await inkRight(h(Txt, { role: "title", align: "right" }, "Invoice"));
|
||||||
|
// pdf.js x1 is the end of the last glyph, without the trailing letter spacing.
|
||||||
|
expect(plain.x1).toBeCloseTo(PAD + 200, 1);
|
||||||
|
expect(Math.abs(tracked.x1 - plain.x1)).toBeLessThanOrEqual(0.5);
|
||||||
|
// Without compensation the ink ends one tracking step early; the test would otherwise prove nothing.
|
||||||
|
expect(plain.x1 - naive.x1).toBeGreaterThan(1.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centres the ink", async () => {
|
||||||
|
const t = await inkRight(h(Tracked, { role: "title", align: "center" }, "Invoice"));
|
||||||
|
expect((t.x0 + t.x1) / 2).toBeCloseTo(PAD + 100, 0);
|
||||||
|
const naive = await inkRight(h(Txt, { role: "title", align: "center" }, "Invoice"));
|
||||||
|
expect(Math.abs((t.x0 + t.x1) / 2 - (PAD + 100))).toBeLessThan(Math.abs((naive.x0 + naive.x1) / 2 - (PAD + 100)) + 1e-6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { createElement as h } from "react";
|
||||||
|
import { Path, Svg, View } from "@react-pdf/renderer";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { chromeHeight } from "../engine/metrics";
|
||||||
|
import { PlanError } from "../engine/errors";
|
||||||
|
import { auditLayout } from "../engine/audit";
|
||||||
|
import { computeFrame, PAGE_SIZES } from "../engine/geometry";
|
||||||
|
import { BAND_ID, DECOR_ID, PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids";
|
||||||
|
import { getMeasurer } from "../engine/measure";
|
||||||
|
import { TYPE_ROLES } from "../fonts/roles";
|
||||||
|
import { renderCore } from "../render/core";
|
||||||
|
import { byId, doc, find, frameFor, PAD } from "../testing/docs";
|
||||||
|
import { setupPdfTest } from "../testing/setup";
|
||||||
|
import { AvailableWidthProvider } from "./AvailableWidth";
|
||||||
|
import { FirstPageHeader } from "./FirstPageHeader";
|
||||||
|
import { planItemsTable, TableRows } from "./ItemsTable";
|
||||||
|
import { PageChrome, pageLabelText } from "./PageChrome";
|
||||||
|
import { guardPresence } from "./TableGuard";
|
||||||
|
|
||||||
|
setupPdfTest();
|
||||||
|
|
||||||
|
const rows = (n: number) => Array.from({ length: n }, (_, i) => h(View, { key: i, style: { height: 20 } }));
|
||||||
|
|
||||||
|
describe("FirstPageHeader", () => {
|
||||||
|
const pad = { top: PAD, right: PAD, bottom: PAD, left: PAD };
|
||||||
|
const decor = [{ x: 300, y: 8, width: 80, height: 40, children: h(Svg, { viewBox: "0 0 10 10", style: { width: 80, height: 40 } }, h(Path, { d: "M0 0 L10 0 L10 10 Z", fill: "#123456" })) }];
|
||||||
|
const page = (n: number) =>
|
||||||
|
doc([h(FirstPageHeader, { key: "h", pad, decor, style: { height: 60 } }, h(View, { style: { height: 10 } })), ...rows(n)]);
|
||||||
|
|
||||||
|
it("places absolute decor at the requested PAGE-space position, flagged as decor", async () => {
|
||||||
|
const r = await renderCore(page(8));
|
||||||
|
const d = byId(r.layout[0], DECOR_ID)[0];
|
||||||
|
expect(d.box.x).toBeCloseTo(300, 1);
|
||||||
|
expect(d.box.y).toBeCloseTo(8, 1);
|
||||||
|
expect(d.box.w).toBe(80);
|
||||||
|
expect(d.decor).toBe(true);
|
||||||
|
expect(find(r.layout[0], (n) => n.type === "svg" && !!n.decor)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the decor on page 1 only, over a multi-page flow", async () => {
|
||||||
|
const r = await renderCore(page(30));
|
||||||
|
expect(r.pages).toBeGreaterThan(1);
|
||||||
|
expect(r.layout.map((p) => find(p, (n) => n.type === "svg").length)).toEqual(r.layout.map((_, i) => (i === 0 ? 1 : 0)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an explicit pageOrigin", async () => {
|
||||||
|
const el = doc([
|
||||||
|
h(View, { key: "gap", style: { height: 30 } }),
|
||||||
|
h(FirstPageHeader, { key: "h", pad, pageOrigin: { x: PAD, y: 50 }, decor: [{ x: 100, y: 60, width: 20, height: 20 }], style: { height: 40 } }),
|
||||||
|
]);
|
||||||
|
const d = byId((await renderCore(el)).layout[0], DECOR_ID)[0];
|
||||||
|
expect([d.box.x, d.box.y]).toEqual([100, 60]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PageChrome", () => {
|
||||||
|
const FOOTER = chromeHeight("footer");
|
||||||
|
const frame = frameFor({ footerReserve: FOOTER, continuationReserve: 14 });
|
||||||
|
const padding = { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + FOOTER };
|
||||||
|
const el = () =>
|
||||||
|
doc(
|
||||||
|
[
|
||||||
|
...rows(40),
|
||||||
|
h(PageChrome, { key: "chrome", frame, footerLeft: "INV-0001", band: { left: "Invoice INV-0001", right: "continued" }, decor: [{ x: 0, y: 0, width: 400, height: 6 }] }),
|
||||||
|
],
|
||||||
|
{ pad: padding },
|
||||||
|
);
|
||||||
|
|
||||||
|
it("draws 'Page k of N' and the footer on every page, the band only from page 2, and audits clean", async () => {
|
||||||
|
const r = await renderCore(el(), { frame });
|
||||||
|
expect(r.pages).toBeGreaterThan(2);
|
||||||
|
r.layout.forEach((p, i) => {
|
||||||
|
expect(byId(p, PAGE_LABEL_ID)[0].lines!.map((l) => l.text)).toEqual([`Page ${i + 1} of ${r.pages}`]);
|
||||||
|
const band = byId(p, BAND_ID).flatMap((n) => n.lines!.map((l) => l.text));
|
||||||
|
expect(band).toEqual(i === 0 ? [] : ["Invoice INV-0001", "continued"]);
|
||||||
|
expect(byId(p, DECOR_ID)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
expect(r.issues).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the footer line inside the reserved strip below the content area", async () => {
|
||||||
|
const r = await renderCore(el());
|
||||||
|
const label = byId(r.layout[0], PAGE_LABEL_ID)[0];
|
||||||
|
const contentBottom = 300 - frame.pad.bottom;
|
||||||
|
expect(label.box.y).toBeGreaterThanOrEqual(contentBottom - 0.01);
|
||||||
|
expect(label.box.y + label.box.h).toBeLessThanOrEqual(300 - PAD + 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins and fits a long left footer text to one line and centres the middle text", async () => {
|
||||||
|
const long = "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt";
|
||||||
|
const el = doc(
|
||||||
|
[...rows(8), h(PageChrome, { key: "chrome", frame, footerLeft: long, footerLeftWidth: 120, footerCenter: "Centre text" })],
|
||||||
|
{ pad: padding },
|
||||||
|
);
|
||||||
|
const r = await renderCore(el, { frame });
|
||||||
|
expect(r.issues).toEqual([]);
|
||||||
|
const texts = find(r.layout[0], (n) => n.type === "text" && n.fixed === true && n.box.y > 250);
|
||||||
|
const left = texts.find((n) => n.text?.startsWith("Venkatachalam"))!;
|
||||||
|
expect(left.lines).toHaveLength(1);
|
||||||
|
expect(left.lines![0].text.endsWith("…")).toBe(true);
|
||||||
|
expect(left.box.w).toBeCloseTo(120, 1);
|
||||||
|
const centre = texts.find((n) => n.lines?.some((l) => l.text === "Centre text"))!;
|
||||||
|
expect(centre.lines).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a footer reserve smaller than the line", () => {
|
||||||
|
const tight = frameFor({ footerReserve: 4 });
|
||||||
|
expect(() => PageChrome({ frame: tight })).toThrow(PlanError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copes with totalPages being undefined on the first pass", () => {
|
||||||
|
expect(pageLabelText(2, undefined)).toBe("Page 2");
|
||||||
|
expect(pageLabelText(2, 0)).toBe("Page 2");
|
||||||
|
expect(pageLabelText(2, 5)).toBe("Page 2 of 5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("planItemsTable / TableRows", () => {
|
||||||
|
const WIDTH = 360;
|
||||||
|
const columns = [{ fill: true as const }, { fixed: 60 }];
|
||||||
|
const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody };
|
||||||
|
const body = [
|
||||||
|
["Short", "1"],
|
||||||
|
["A much longer item description that has to wrap onto several lines in a narrow description column", "2"],
|
||||||
|
["Mid length item", "3"],
|
||||||
|
];
|
||||||
|
const plan = () => planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 });
|
||||||
|
|
||||||
|
it("allocates the columns exactly and sizes rows from wrapped lines", () => {
|
||||||
|
const p = plan();
|
||||||
|
expect(p.widths.reduce((a, b) => a + b, 0) + 8).toBeCloseTo(WIDTH, 2);
|
||||||
|
expect(p.rowLines[0]).toBe(1);
|
||||||
|
expect(p.rowLines[1]).toBeGreaterThan(1);
|
||||||
|
expect(p.rowHeights[0]).toBeCloseTo(13.8 + 8 + 0.5, 5);
|
||||||
|
expect(p.firstRowH).toBe(p.rowHeights[0]);
|
||||||
|
expect(p.headH).toBeCloseTo(10 + 8 + 0.5, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the heights react-pdf lays out", async () => {
|
||||||
|
const p = plan();
|
||||||
|
const el = doc(
|
||||||
|
h(AvailableWidthProvider, { width: WIDTH }, h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, roles, padY: 4, border: 0.5 })),
|
||||||
|
);
|
||||||
|
const r = await renderCore(el);
|
||||||
|
const page = r.layout[0];
|
||||||
|
expect(byId(page, TABLE_HEAD_ID)[0].box.h).toBeCloseTo(p.headH, 1);
|
||||||
|
body.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multi-line cells (one Txt per line) and per-column right padding match the laid-out heights", async () => {
|
||||||
|
const multi = [
|
||||||
|
[["First line", "Second line", "Third line that is long enough to wrap onto another line in a narrow column"], "1"],
|
||||||
|
["Single", "2"],
|
||||||
|
];
|
||||||
|
const padRight = [6, 0];
|
||||||
|
const p = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: multi, measurer: getMeasurer(), roles, padY: 4, border: 0.5, padRight });
|
||||||
|
expect(p.rowLines[0]).toBeGreaterThanOrEqual(4);
|
||||||
|
const el = doc(
|
||||||
|
h(AvailableWidthProvider, { width: WIDTH }, h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: multi, roles, padY: 4, border: 0.5, padRight })),
|
||||||
|
);
|
||||||
|
const page = (await renderCore(el)).layout[0];
|
||||||
|
multi.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a typed PlanError when the totals tail can never share a page with the header and last row", () => {
|
||||||
|
const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 };
|
||||||
|
const p = plan();
|
||||||
|
const lastRow = p.rowHeights[p.rowHeights.length - 1];
|
||||||
|
const ok = p.headH + lastRow + 100;
|
||||||
|
expect(() => planItemsTable({ ...base, tailH: 100, contentH: ok })).not.toThrow();
|
||||||
|
try {
|
||||||
|
planItemsTable({ ...base, tailH: 100, contentH: ok - 1 });
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(PlanError);
|
||||||
|
expect((e as PlanError).code).toBe("tail-too-tall");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed plans", () => {
|
||||||
|
const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], measurer: getMeasurer(), roles };
|
||||||
|
expect(() => planItemsTable({ ...base, rows: [] })).toThrow(PlanError);
|
||||||
|
expect(() => planItemsTable({ ...base, rows: [["only one"]] })).toThrow(PlanError);
|
||||||
|
expect(() => planItemsTable({ ...base, rows: [["a\nb", "1"]] })).toThrow(PlanError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the last row carries the tail's minPresenceAhead: totals never start a page without an item row", async () => {
|
||||||
|
const many = Array.from({ length: 24 }, (_, i) => [`Item ${i + 1}`, `${i + 1}`]);
|
||||||
|
const p = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: many, measurer: getMeasurer(), roles, padY: 4 });
|
||||||
|
const TAIL = 60;
|
||||||
|
// Fill page 1 so the last row would land right at the foot with the tail not fitting after it.
|
||||||
|
const build = (tailH?: number) =>
|
||||||
|
doc(
|
||||||
|
h(
|
||||||
|
AvailableWidthProvider,
|
||||||
|
{ width: WIDTH },
|
||||||
|
h(
|
||||||
|
TableRows,
|
||||||
|
{ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: many, roles, padY: 4, tailH, tail: h(View, { id: "totals", style: { height: TAIL }, wrap: false }) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ height: Math.ceil(PAD * 2 + p.headH + p.rowHeights.reduce((a, b) => a + b, 0) + 10) },
|
||||||
|
);
|
||||||
|
const withTail = await renderCore(build(TAIL));
|
||||||
|
const totalsPage = withTail.layout.findIndex((pg) => byId(pg, "totals").length > 0);
|
||||||
|
const rowsOnTotalsPage = find(withTail.layout[totalsPage], (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX)).length;
|
||||||
|
expect(rowsOnTotalsPage).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const without = await renderCore(build(undefined));
|
||||||
|
const wp = without.layout.findIndex((pg) => byId(pg, "totals").length > 0);
|
||||||
|
expect(find(without.layout[wp], (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guardPresence caps the first row at three body lines", () => {
|
||||||
|
expect(guardPresence(18, 20, 13.8)).toBeCloseTo(38, 5);
|
||||||
|
expect(guardPresence(18, 200, 13.8)).toBeCloseTo(18 + 3 * 13.8, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("audit of a full block composition", () => {
|
||||||
|
it("a framed page with chrome and table is clean", async () => {
|
||||||
|
const FOOTER = chromeHeight("footer");
|
||||||
|
const frame = computeFrame({ page: { ...PAGE_SIZES.A4, width: 400, height: 300 }, margins: { top: PAD, right: PAD, bottom: PAD, left: PAD }, footerReserve: FOOTER, continuationReserve: 14, letterMode: false });
|
||||||
|
const WIDTH = frame.contentWidth;
|
||||||
|
const cols = [{ fill: true as const }, { fixed: 50 }];
|
||||||
|
const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody };
|
||||||
|
const items = Array.from({ length: 40 }, (_, i) => [`Item number ${i + 1}`, `${i + 1}`]);
|
||||||
|
const el = doc(
|
||||||
|
[
|
||||||
|
h(AvailableWidthProvider, { key: "t", width: WIDTH }, h(TableRows, { columns: cols, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: items, roles, padY: 3 })),
|
||||||
|
h(PageChrome, { key: "c", frame, footerLeft: "INV-1", band: { left: "INV-1", right: "continued" } }),
|
||||||
|
],
|
||||||
|
{ pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + FOOTER } },
|
||||||
|
);
|
||||||
|
const r = await renderCore(el);
|
||||||
|
expect(auditLayout({ pages: r.layout }, frame, {})).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface PdfFlagsInput {
|
||||||
|
fixed?: boolean;
|
||||||
|
wrap?: boolean;
|
||||||
|
minPresenceAhead?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* react-pdf checks `fixed`, `wrap` and `minPresenceAhead` by key presence: `fixed={false}` still
|
||||||
|
* disables breaking and `minPresenceAhead={undefined}` changes behaviour too. Every primitive spreads
|
||||||
|
* this result instead of passing the props directly (lint enforces it).
|
||||||
|
*/
|
||||||
|
export function pdfFlags({ fixed, wrap, minPresenceAhead }: PdfFlagsInput = {}): {
|
||||||
|
fixed?: true;
|
||||||
|
wrap?: false;
|
||||||
|
minPresenceAhead?: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
...(fixed ? { fixed: true as const } : {}),
|
||||||
|
...(wrap === false ? { wrap: false as const } : {}),
|
||||||
|
...(typeof minPresenceAhead === "number" && Number.isFinite(minPresenceAhead) && minPresenceAhead > 0
|
||||||
|
? { minPresenceAhead }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Image, View } from "@react-pdf/renderer";
|
||||||
|
import { pinned } from "../engine/columns";
|
||||||
|
import { textLines } from "../engine/textplan";
|
||||||
|
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import type { RenderModel } from "../model/build";
|
||||||
|
import { useAvailableWidth } from "./AvailableWidth";
|
||||||
|
import { IdText } from "./IdText";
|
||||||
|
import { Row } from "./Row";
|
||||||
|
import { SafeTxt } from "./SafeTxt";
|
||||||
|
import { Txt } from "./Txt";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Regulated data (GSTIN, place of supply, HSN/SAC, tax rows, amount in words, signatory, reverse
|
||||||
|
* charge) is printed ONLY through these components, so one contract test can prove that every
|
||||||
|
* template carries what a document type requires (model/slots.ts) and nothing it forbids. Each slot
|
||||||
|
* stamps an id (`slot-*`) on its output so the test finds it in the layout tree. Templates must not
|
||||||
|
* read these model fields themselves (slots.lint.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SLOT_ID = {
|
||||||
|
gstin: "slot-gstin",
|
||||||
|
pos: "slot-pos",
|
||||||
|
taxRows: "slot-tax-rows",
|
||||||
|
amountInWords: "slot-words",
|
||||||
|
signatory: "slot-signatory",
|
||||||
|
reverseCharge: "slot-reverse-charge",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Role = RoleName | TypeToken;
|
||||||
|
|
||||||
|
const INK_SUB = "#393939";
|
||||||
|
const FINE = "#6f6f6f";
|
||||||
|
const SIGNATURE_RULE = "#8d8d8d";
|
||||||
|
|
||||||
|
function Gstin({ model, party = "supplier", role = "body", color }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string }) {
|
||||||
|
const value = party === "supplier" ? (model.slots.gstin ? model.supplier.gstin : null) : model.client.gstin;
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<View id={`${SLOT_ID.gstin}-${party}`}>
|
||||||
|
<IdText role={role} color={color}>{`GSTIN: ${value}`}</IdText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceOfSupply({ model, role = "body", color }: { model: RenderModel; role?: Role; color?: string }) {
|
||||||
|
if (!model.client.posLabel) return null;
|
||||||
|
return (
|
||||||
|
<SafeTxt id={SLOT_ID.pos} role={role} color={color}>
|
||||||
|
{`Place of Supply: ${model.client.posLabel}`}
|
||||||
|
</SafeTxt>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The HSN/SAC column: header and one value per item row, or null when no item carries a code. */
|
||||||
|
function hsnSacColumn(model: RenderModel): { header: string; values: string[] } | null {
|
||||||
|
if (!model.slots.hsn) return null;
|
||||||
|
return { header: "HSN/SAC", values: model.items.map((i) => i.hsnSac ?? "—") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxRowsProps {
|
||||||
|
model: RenderModel;
|
||||||
|
role?: Role;
|
||||||
|
amountWidth: number;
|
||||||
|
rowStyle?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CGST/SGST (UTGST) or IGST rows, as label (rate) on the left and the amount on the right. */
|
||||||
|
function TaxRows({ model, role = "body", amountWidth, rowStyle }: TaxRowsProps) {
|
||||||
|
const w = useAvailableWidth();
|
||||||
|
if (model.totals.taxRows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<View id={SLOT_ID.taxRows}>
|
||||||
|
{model.totals.taxRows.map((r, i) => (
|
||||||
|
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} width={w} style={rowStyle}>
|
||||||
|
<Txt role={role} width={w - amountWidth}>{r.rateText ? `${r.label} (${r.rateText})` : r.label}</Txt>
|
||||||
|
<Txt role={role} width={amountWidth} align="right">{r.amountText}</Txt>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmountInWords({ model, role = "body", style }: { model: RenderModel; role?: Role; style?: Record<string, unknown> }) {
|
||||||
|
if (!model.amountInWords) return null;
|
||||||
|
return (
|
||||||
|
<SafeTxt id={SLOT_ID.amountInWords} role={role} italic style={style}>
|
||||||
|
{`Amount in words: ${model.amountInWords}`}
|
||||||
|
</SafeTxt>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReverseCharge({ model, role = "body", color, style }: { model: RenderModel; role?: Role; color?: string; style?: Record<string, unknown> }) {
|
||||||
|
if (!model.reverseCharge) return null;
|
||||||
|
return (
|
||||||
|
<SafeTxt id={SLOT_ID.reverseCharge} role={role} color={color} style={style}>
|
||||||
|
{model.reverseCharge}
|
||||||
|
</SafeTxt>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Predicted heights of the slots that sit in the totals block, for its presence value. */
|
||||||
|
export const slotHeights = {
|
||||||
|
/** Number of tax rows (each is one body line plus the template's row padding). */
|
||||||
|
taxRowCount: (model: RenderModel): number => model.totals.taxRows.length,
|
||||||
|
amountInWords: (model: RenderModel, width: number): number =>
|
||||||
|
model.amountInWords ? textLines("body", `Amount in words: ${model.amountInWords}`, width, { italic: true }) * (roleToken("body").leading as number) : 0,
|
||||||
|
reverseCharge: (model: RenderModel, width: number): number =>
|
||||||
|
model.reverseCharge ? textLines("small", model.reverseCharge, width) * (roleToken("small").leading as number) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIGNATURE_IMAGE_H = 58;
|
||||||
|
const FINE_LINE = "Computer-generated invoice; no signature is required.";
|
||||||
|
|
||||||
|
/** Height of the signatory block at `width`, for the closing block's presence value. */
|
||||||
|
export function signatoryHeight(model: RenderModel, width: number): number {
|
||||||
|
const leading = (r: Role) => roleToken(r).leading as number;
|
||||||
|
const { name, designation, dataUri } = model.signature;
|
||||||
|
let h = (dataUri ? SIGNATURE_IMAGE_H : 0) + 4 + 0.8 + 3;
|
||||||
|
h += textLines("bodyStrong", name, width) * leading("bodyStrong");
|
||||||
|
if (designation) h += textLines("body", designation, width) * leading("body");
|
||||||
|
if (!dataUri) h += 6 + textLines("fine", FINE_LINE, width) * leading("fine");
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature image (if any), a rule, the signatory's name and designation. Without an image the
|
||||||
|
* "computer-generated" line stands in for the signature.
|
||||||
|
*/
|
||||||
|
function Signatory({ model, width }: { model: RenderModel; width?: number }) {
|
||||||
|
const avail = useAvailableWidth();
|
||||||
|
const w = width ?? avail;
|
||||||
|
const { name, designation, dataUri } = model.signature;
|
||||||
|
return (
|
||||||
|
<View id={SLOT_ID.signatory} style={pinned(w) as never}>
|
||||||
|
{dataUri ? (
|
||||||
|
// Pinned box (an Image may be pinned); the picture sits at its right and bottom edge, just above the rule.
|
||||||
|
<Image
|
||||||
|
src={dataUri}
|
||||||
|
style={{ ...pinned(w), height: SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: "100%", objectPositionY: "100%" } as never}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View style={{ marginTop: 4, paddingTop: 3, borderTopWidth: 0.8, borderTopColor: SIGNATURE_RULE, ...pinned(w) } as never}>
|
||||||
|
<SafeTxt role="bodyStrong" width={w} align="right">{name}</SafeTxt>
|
||||||
|
{designation ? (
|
||||||
|
<SafeTxt role="body" width={w} align="right" color={INK_SUB}>{designation}</SafeTxt>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{!dataUri ? (
|
||||||
|
<Txt role="fine" color={FINE} width={w} align="right" style={{ marginTop: 6 }}>
|
||||||
|
{FINE_LINE}
|
||||||
|
</Txt>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge };
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { createElement as h, type ReactNode } from "react";
|
||||||
|
import { Image, Svg, Text, View } from "@react-pdf/renderer";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BottomSpacer } from "../blocks/BottomSpacer";
|
||||||
|
import { PageChrome } from "../blocks/PageChrome";
|
||||||
|
import { RenderTxt } from "../blocks/RenderTxt";
|
||||||
|
import { Tracked } from "../blocks/Tracked";
|
||||||
|
import { AvailableWidthProvider } from "../blocks/AvailableWidth";
|
||||||
|
import { renderCore } from "../render/core";
|
||||||
|
import { byId, doc, frameFor, PAD, txt } from "../testing/docs";
|
||||||
|
import { setupPdfTest } from "../testing/setup";
|
||||||
|
import { auditLayout, type AuditCode, type AuditOptions } from "./audit";
|
||||||
|
import { BAND_ID, DECOR_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./ids";
|
||||||
|
|
||||||
|
setupPdfTest();
|
||||||
|
|
||||||
|
const FRAME = frameFor();
|
||||||
|
|
||||||
|
/** Renders `el` and audits it against `frame`; returns the issue codes (and the issues themselves). */
|
||||||
|
async function audit(el: ReturnType<typeof doc>, opts: AuditOptions = {}, frame = FRAME) {
|
||||||
|
const r = await renderCore(el);
|
||||||
|
const issues = auditLayout({ pages: r.layout }, frame, opts);
|
||||||
|
return { codes: issues.map((i) => i.code) as AuditCode[], issues, r };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LONG = "Aurora Industrial Fasteners and Precision Components Private Limited, Plot 14, Phase 2, Peenya Industrial Area, Bengaluru, Karnataka";
|
||||||
|
const abs = (style: Record<string, unknown>) => ({ position: "absolute" as const, ...style });
|
||||||
|
|
||||||
|
describe("auditLayout", () => {
|
||||||
|
describe("overlap", () => {
|
||||||
|
it("flags two text nodes whose line boxes intersect", async () => {
|
||||||
|
const el = doc([
|
||||||
|
h(Text, { key: "a", style: txt() }, "Vendor address line"),
|
||||||
|
h(Text, { key: "b", style: txt({ ...abs({ top: 22, left: 40 }) }) }, "INVOICE"),
|
||||||
|
]);
|
||||||
|
const { codes, issues } = await audit(el);
|
||||||
|
expect(codes).toContain("overlap");
|
||||||
|
expect(issues.find((i) => i.code === "overlap")!.box).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes stacked text, and ignores a text overlapping decor", async () => {
|
||||||
|
expect((await audit(doc([h(Text, { key: "a", style: txt() }, "One"), h(Text, { key: "b", style: txt() }, "Two")]))).codes).toEqual([]);
|
||||||
|
const withDecor = doc([
|
||||||
|
h(Text, { key: "a", style: txt() }, "Vendor address line"),
|
||||||
|
h(View, { key: "d", id: DECOR_ID, style: abs({ top: 20, left: 20 }) }, h(Text, { style: txt() }, "Vendor address line")),
|
||||||
|
]);
|
||||||
|
expect((await audit(withDecor)).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overflow-x", () => {
|
||||||
|
const row = (fill: Record<string, unknown>) =>
|
||||||
|
doc(
|
||||||
|
h(
|
||||||
|
View,
|
||||||
|
{ style: { flexDirection: "row", width: 360 } },
|
||||||
|
h(Text, { style: txt(fill) }, LONG),
|
||||||
|
h(View, { style: { width: 180, minWidth: 180, maxWidth: 180 } }, h(Text, { style: txt() }, "side")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("flags text whose stale line breaks run wider than its final box", async () => {
|
||||||
|
const { codes, issues } = await audit(row({ flexGrow: 1 }));
|
||||||
|
expect(codes).toContain("overflow-x");
|
||||||
|
expect(issues.some((i) => i.code === "overlap")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the same row with flexBasis 0", async () => {
|
||||||
|
expect((await audit(row({ flexGrow: 1, flexBasis: 0 }))).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures ink: tracked right-aligned text may overhang its box by the trailing letterSpacing", async () => {
|
||||||
|
const el = doc(
|
||||||
|
h(
|
||||||
|
AvailableWidthProvider,
|
||||||
|
{ width: 200 },
|
||||||
|
h(View, { style: { width: 200 } }, h(Tracked, { role: "title", align: "right" }, "Invoice")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { codes, r } = await audit(el);
|
||||||
|
expect(codes).toEqual([]);
|
||||||
|
// The box does overhang the page margin by one tracking step; the ink does not.
|
||||||
|
const t = r.layout[0].nodes[0].children[0];
|
||||||
|
expect(t.box.x + t.box.w).toBeGreaterThan(PAD + 200 + 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags ink beyond the page margin box", async () => {
|
||||||
|
const el = doc(h(Text, { style: txt({ width: 500 }) }, "Short"), { pad: PAD });
|
||||||
|
expect((await audit(doc(h(Text, { style: txt({ ...abs({ left: 340 }) }) }, "Overruns the right margin")))).codes).toContain("overflow-x");
|
||||||
|
expect((await audit(el)).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outside-safe-zone", () => {
|
||||||
|
it("flags a non-decor, non-fixed node outside the margin box", async () => {
|
||||||
|
const el = doc(h(View, { id: "stray", style: abs({ top: 2, left: 2, width: 30, height: 10 }) }));
|
||||||
|
const { codes, issues } = await audit(el);
|
||||||
|
expect(codes).toContain("outside-safe-zone");
|
||||||
|
expect(issues[0].message).toContain("#stray");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows decor anywhere and fixed chrome inside the page edge, but not beyond it", async () => {
|
||||||
|
const decor = doc(h(View, { id: DECOR_ID, style: abs({ top: -5, left: -5, width: 30, height: 10 }) }));
|
||||||
|
expect((await audit(decor)).codes).toEqual([]);
|
||||||
|
const chrome = (top: number) => doc(h(View, { fixed: true, style: abs({ top, left: 4, width: 30, height: 10 }) }));
|
||||||
|
expect((await audit(chrome(2))).codes).toEqual([]);
|
||||||
|
expect((await audit(chrome(-4))).codes).toContain("outside-safe-zone");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("below-wrap-area", () => {
|
||||||
|
it("flags non-fixed content that ends under the content bottom", async () => {
|
||||||
|
// The page really has no bottom padding, but the frame says the wrap area ends 20 pt up.
|
||||||
|
const el = doc(h(View, { id: "tall", style: { height: 285 } }), { pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: 0 } });
|
||||||
|
const { codes, issues } = await audit(el);
|
||||||
|
expect(codes).toContain("below-wrap-area");
|
||||||
|
expect(issues.find((i) => i.code === "below-wrap-area")!.message).toContain("#tall");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes content inside the wrap area, fixed chrome below it and decor below it", async () => {
|
||||||
|
const el = doc([
|
||||||
|
h(View, { key: "a", style: { height: 100 } }),
|
||||||
|
h(View, { key: "b", fixed: true, style: abs({ bottom: 4, left: PAD, width: 40, height: 10 }) }),
|
||||||
|
h(View, { key: "c", id: DECOR_ID, style: abs({ bottom: 0, left: PAD, width: 40, height: 10 }) }),
|
||||||
|
]);
|
||||||
|
expect((await audit(el)).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("text-truncated", () => {
|
||||||
|
const name = "Aurora Industrial Fasteners and Precision Components Private Limited of Peenya";
|
||||||
|
const box = (sizing: Record<string, unknown>, style: Record<string, unknown> = {}) =>
|
||||||
|
doc(h(View, { style: { width: 100, ...sizing } }, h(Text, { style: txt(style) }, name)));
|
||||||
|
|
||||||
|
it("flags lines lost to a pinned height, with the unexpected ellipsis noted", async () => {
|
||||||
|
const { issues } = await audit(box({ height: 33 }));
|
||||||
|
const t = issues.find((i) => i.code === "text-truncated")!;
|
||||||
|
expect(t.severity).toBe("error");
|
||||||
|
expect(t.message).toContain("unexpected ellipsis");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a minHeight box, and text that really contains an ellipsis", async () => {
|
||||||
|
expect((await audit(box({ minHeight: 33 }))).codes).toEqual([]);
|
||||||
|
expect((await audit(doc(h(Text, { style: txt() }, "Wait… what")))).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downgrades an authored maxLines clamp to a warning", async () => {
|
||||||
|
const { issues } = await audit(box({}, { maxLines: 2, textOverflow: "ellipsis" }));
|
||||||
|
expect(issues.filter((i) => i.code === "text-truncated").map((i) => i.severity)).toEqual(["warn"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares through whitespace and caps: uppercase transform and trailing spaces are not truncation", async () => {
|
||||||
|
expect((await audit(doc(h(Text, { style: txt({ textTransform: "uppercase", width: 60 }) }, "tax invoice for goods")))).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("render-text-empty", () => {
|
||||||
|
const role = { face: "IBM Plex Sans" as const, weight: 400 as const, size: 8 };
|
||||||
|
const chrome = (viewStyle: Record<string, unknown>) =>
|
||||||
|
doc([
|
||||||
|
h(View, { key: "x", style: { height: 400 } }),
|
||||||
|
h(View, { key: "c", fixed: true, style: abs({ bottom: 4, left: PAD, width: 100, ...viewStyle }) }, h(RenderTxt, { role, render: ({ pageNumber }) => `Page ${pageNumber}` })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("flags render-prop text that drew 0 lines in a too-short fixed box", async () => {
|
||||||
|
expect((await audit(chrome({ height: 10 }))).codes).toContain("render-text-empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the same text in an auto-height box", async () => {
|
||||||
|
expect((await audit(chrome({}))).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the continuation band be empty on page 1 only", async () => {
|
||||||
|
const el = doc(
|
||||||
|
[
|
||||||
|
h(View, { key: "x", style: { height: 600 } }),
|
||||||
|
h(PageChrome, { key: "chrome", frame: frameFor({ footerReserve: 14 }), pageLabel: false, band: { left: "Invoice INV-1", right: "continued" } }),
|
||||||
|
],
|
||||||
|
{ pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + 14 } },
|
||||||
|
);
|
||||||
|
const { codes, r } = await audit(el, {}, frameFor({ footerReserve: 14 }));
|
||||||
|
expect(r.pages).toBeGreaterThan(1);
|
||||||
|
expect(codes).toEqual([]);
|
||||||
|
expect(byId(r.layout[0], BAND_ID).every((n) => n.lines!.length === 0)).toBe(true);
|
||||||
|
expect(byId(r.layout[1], BAND_ID).every((n) => n.lines!.length > 0)).toBe(true);
|
||||||
|
// The same empty text on page 2 would be an error: a custom id list does not excuse it.
|
||||||
|
const strict = auditLayout({ pages: r.layout }, frameFor({ footerReserve: 14 }), { optionalFirstPageIds: [] });
|
||||||
|
expect(strict.map((i) => i.code)).toContain("render-text-empty");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("header-only-page", () => {
|
||||||
|
const table = (fillerH: number) =>
|
||||||
|
doc([
|
||||||
|
h(View, { key: "f", style: { height: fillerH } }),
|
||||||
|
h(
|
||||||
|
View,
|
||||||
|
{ key: "t" },
|
||||||
|
h(View, { key: "h", id: TABLE_HEAD_ID, fixed: true, style: { height: 10 } }),
|
||||||
|
...Array.from({ length: 12 }, (_, i) => h(View, { key: i, id: `${TABLE_ROW_PREFIX}${i}`, style: { height: 20 }, wrap: false })),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("flags a page whose only table content is the repeated header", async () => {
|
||||||
|
const { issues } = await audit(table(250));
|
||||||
|
expect(issues.find((i) => i.code === "header-only-page")!.page).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes when every page with the header has rows", async () => {
|
||||||
|
expect((await audit(table(100))).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keepout", () => {
|
||||||
|
const el = () => doc([h(Text, { key: "a", style: txt() }, "Near the corner"), h(Text, { key: "b", style: txt() }, "Second")]);
|
||||||
|
it("flags text lines entering a keep-out rectangle", async () => {
|
||||||
|
const { issues } = await audit(el(), { keepOuts: [{ x: 0, y: 0, w: 120, h: 30 }] });
|
||||||
|
expect(issues.filter((i) => i.code === "keepout").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes content clear of the rectangle, a rectangle on another page, and decor inside it", async () => {
|
||||||
|
expect((await audit(el(), { keepOuts: [{ x: 200, y: 100, w: 100, h: 100 }] })).codes).toEqual([]);
|
||||||
|
expect((await audit(el(), { keepOuts: [{ x: 0, y: 0, w: 120, h: 30, page: 2 }] })).codes).toEqual([]);
|
||||||
|
const decor = doc(h(Svg, { id: DECOR_ID, style: abs({ top: 20, left: 20, width: 50, height: 20 }) }));
|
||||||
|
expect((await audit(decor, { keepOuts: [{ x: 0, y: 0, w: 120, h: 60 }] })).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also checks images and Svg", async () => {
|
||||||
|
const png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||||
|
const withImage = doc(h(Image, { src: png, style: { width: 40, height: 20 } }));
|
||||||
|
expect((await audit(withImage, { keepOuts: [{ x: 0, y: 0, w: 120, h: 60 }] })).codes).toContain("keepout");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("split-block", () => {
|
||||||
|
// Four 60 pt rows fill the wrap area to 260 of 280; the fifth straddles the foot.
|
||||||
|
const rows = (fifth: Record<string, unknown>) =>
|
||||||
|
doc([0, 1, 2, 3, 4].map((i) => h(View, { key: i, id: `row-${i}`, style: { height: 60 }, ...(i === 4 ? fifth : { wrap: false }) }, h(Text, { style: txt() }, `R${i}`))));
|
||||||
|
|
||||||
|
it("flags a wrap={false} block that appears on two pages", async () => {
|
||||||
|
const { issues } = await audit(rows({ wrap: false, fixed: false }));
|
||||||
|
expect(issues.find((i) => i.code === "split-block")!.message).toContain("#row-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a wrap={false} block that moved whole, and ignores fixed chrome repeating on every page", async () => {
|
||||||
|
expect((await audit(rows({ wrap: false }))).codes).toEqual([]);
|
||||||
|
const el = doc([
|
||||||
|
...Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } })),
|
||||||
|
h(View, { key: "f", id: "footer", fixed: true, style: abs({ bottom: 4, left: PAD, width: 20, height: 10 }), wrap: false }),
|
||||||
|
]);
|
||||||
|
expect((await audit(el)).codes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("closing-not-at-bottom", () => {
|
||||||
|
const body = (spacer: ReactNode) =>
|
||||||
|
doc(
|
||||||
|
[
|
||||||
|
...Array.from({ length: 16 }, (_, i) => h(View, { key: i, style: { height: 20 } })),
|
||||||
|
spacer,
|
||||||
|
h(View, { key: "closing", id: "closing", style: { height: 50 }, wrap: false }, h(Text, { style: txt() }, "Amount in words")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
it("flags a closing block that is not at the wrap-area bottom of the last page", async () => {
|
||||||
|
const { issues } = await audit(body(null), { closingId: "closing" });
|
||||||
|
expect(issues.find((i) => i.code === "closing-not-at-bottom")!.page).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes with a BottomSpacer, and reports a closing block missing from the last page", async () => {
|
||||||
|
expect((await audit(body(h(BottomSpacer, { key: "sp", closingH: 50 })), { closingId: "closing" })).codes).toEqual([]);
|
||||||
|
const { issues } = await audit(body(null), { closingId: "nope" });
|
||||||
|
expect(issues.find((i) => i.code === "closing-not-at-bottom")!.message).toContain("not on the last page");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { PageFrame } from "./geometry";
|
||||||
|
import { BAND_ID, DECOR_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./ids";
|
||||||
|
import { walk, type LayoutLine, type LayoutNode, type LayoutPage, type Rect } from "./layoutTree";
|
||||||
|
|
||||||
|
export type AuditCode =
|
||||||
|
| "overlap"
|
||||||
|
| "overflow-x"
|
||||||
|
| "outside-safe-zone"
|
||||||
|
| "below-wrap-area"
|
||||||
|
| "text-truncated"
|
||||||
|
| "render-text-empty"
|
||||||
|
| "header-only-page"
|
||||||
|
| "keepout"
|
||||||
|
| "split-block"
|
||||||
|
| "closing-not-at-bottom";
|
||||||
|
|
||||||
|
export interface AuditIssue {
|
||||||
|
code: AuditCode;
|
||||||
|
severity: "error" | "warn";
|
||||||
|
/** 1-based. */
|
||||||
|
page: number;
|
||||||
|
message: string;
|
||||||
|
box?: Rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditOptions {
|
||||||
|
/** Rectangles (page space) that no content may touch; `page` limits one to a single 1-based page. */
|
||||||
|
keepOuts?: Array<Rect & { page?: number }>;
|
||||||
|
/** Id of the closing block; its bottom must sit on the wrap-area bottom of the last page. */
|
||||||
|
closingId?: string;
|
||||||
|
/** Render-prop text ids that may draw nothing on page 1 (the continuation band). */
|
||||||
|
optionalFirstPageIds?: string[];
|
||||||
|
/** Header row id and body row id prefix for header-only-page. */
|
||||||
|
headId?: string;
|
||||||
|
rowPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overlaps and overruns smaller than this are rounding noise. */
|
||||||
|
const TOL = 0.5;
|
||||||
|
|
||||||
|
const right = (r: Rect) => r.x + r.w;
|
||||||
|
const bottom = (r: Rect) => r.y + r.h;
|
||||||
|
const intersects = (a: Rect, b: Rect, tol = TOL) =>
|
||||||
|
Math.min(right(a), right(b)) - Math.max(a.x, b.x) > tol && Math.min(bottom(a), bottom(b)) - Math.max(a.y, b.y) > tol;
|
||||||
|
|
||||||
|
/** Where the glyphs really are: the advance minus the letterSpacing step that follows the last glyph. */
|
||||||
|
function inkRect(line: LayoutLine, node: LayoutNode): Rect {
|
||||||
|
const ls = node.letterSpacing ?? 0;
|
||||||
|
return { x: line.x, y: line.y, w: Math.max(0, line.w - ls), h: line.h };
|
||||||
|
}
|
||||||
|
|
||||||
|
const squash = (s: string) => s.replace(/[\u00ad\u2028\u2029\u2060]/g, "").replace(/\s+/g, "");
|
||||||
|
|
||||||
|
const fmt = (r: Rect) => `[x ${r.x.toFixed(1)}..${right(r).toFixed(1)}, y ${r.y.toFixed(1)}..${bottom(r).toFixed(1)}]`;
|
||||||
|
const label = (n: LayoutNode) => `${n.id ? `#${n.id} ` : ""}${n.type}${n.text ? ` "${n.text.slice(0, 40)}"` : ""}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the laid-out document against the frame. Pure: reads only the normalised tree. Every check
|
||||||
|
* ignores decor (DECOR_ID subtrees) unless noted.
|
||||||
|
*/
|
||||||
|
export function auditLayout(layout: { pages: LayoutPage[] }, frame: PageFrame, opts: AuditOptions = {}): AuditIssue[] {
|
||||||
|
const issues: AuditIssue[] = [];
|
||||||
|
const optionalFirst = new Set(opts.optionalFirstPageIds ?? [BAND_ID]);
|
||||||
|
const headId = opts.headId ?? TABLE_HEAD_ID;
|
||||||
|
const rowPrefix = opts.rowPrefix ?? TABLE_ROW_PREFIX;
|
||||||
|
const { pad } = frame;
|
||||||
|
const marginBottom = pad.bottom - frame.footerReserve;
|
||||||
|
|
||||||
|
layout.pages.forEach((page, idx) => {
|
||||||
|
const pageNo = idx + 1;
|
||||||
|
const add = (code: AuditCode, message: string, box?: Rect, severity: "error" | "warn" = "error") =>
|
||||||
|
issues.push({ code, severity, page: pageNo, message, ...(box ? { box } : {}) });
|
||||||
|
|
||||||
|
const margin: Rect = { x: pad.left, y: pad.top, w: page.width - pad.left - pad.right, h: page.height - pad.top - marginBottom };
|
||||||
|
const pageRect: Rect = { x: 0, y: 0, w: page.width, h: page.height };
|
||||||
|
const contentBottom = page.height - pad.bottom;
|
||||||
|
|
||||||
|
const all: Array<{ node: LayoutNode; parent: LayoutNode | undefined }> = [];
|
||||||
|
walk(page.nodes, (node, parent) => all.push({ node, parent }));
|
||||||
|
const live = all.filter((e) => !e.node.decor);
|
||||||
|
|
||||||
|
// overlap: lines of two different text nodes.
|
||||||
|
const lines: Array<{ node: LayoutNode; line: LayoutLine }> = [];
|
||||||
|
for (const { node } of live) {
|
||||||
|
if (node.type !== "text") continue;
|
||||||
|
for (const line of node.lines ?? []) if (line.text.trim() !== "") lines.push({ node, line });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
for (let j = i + 1; j < lines.length; j++) {
|
||||||
|
if (lines[i].node === lines[j].node) continue;
|
||||||
|
const a = inkRect(lines[i].line, lines[i].node);
|
||||||
|
const b = inkRect(lines[j].line, lines[j].node);
|
||||||
|
if (intersects(a, b)) {
|
||||||
|
add("overlap", `"${lines[i].line.text}" ${fmt(a)} overlaps "${lines[j].line.text}" ${fmt(b)}`, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { node, parent } of live) {
|
||||||
|
if (node.type === "text") {
|
||||||
|
const ink = (node.lines ?? []).map((l) => inkRect(l, node)).filter((r) => r.w > 0);
|
||||||
|
|
||||||
|
// overflow-x: ink past its own box, its container, or the page margin box.
|
||||||
|
for (const [i, r] of ink.entries()) {
|
||||||
|
const ln = node.lines![i];
|
||||||
|
const text = `"${ln.text}"`;
|
||||||
|
if (right(r) > right(node.box) + TOL || r.x < node.box.x - TOL) {
|
||||||
|
add("overflow-x", `${text} ink ${fmt(r)} runs outside its own box ${fmt(node.box)} (stale line breaks?)`, r);
|
||||||
|
} else if (parent && parent.type === "view" && (right(r) > right(parent.box) + TOL || r.x < parent.box.x - TOL)) {
|
||||||
|
add("overflow-x", `${text} ink ${fmt(r)} runs outside its container ${fmt(parent.box)}`, r);
|
||||||
|
} else if (right(r) > right(margin) + TOL || r.x < margin.x - TOL) {
|
||||||
|
add("overflow-x", `${text} ink ${fmt(r)} runs outside the margin box ${fmt(margin)}`, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// text-truncated
|
||||||
|
if (!node.render && node.text !== undefined) {
|
||||||
|
const want = squash(node.text);
|
||||||
|
const got = squash((node.lines ?? []).map((l) => l.text).join(" "));
|
||||||
|
if (want !== got) {
|
||||||
|
const ellipsis = got.includes("…") && !want.includes("…");
|
||||||
|
add(
|
||||||
|
"text-truncated",
|
||||||
|
`${label(node)} drew "${(node.lines ?? []).map((l) => l.text).join(" ")}"${ellipsis ? " (unexpected ellipsis)" : ""}`,
|
||||||
|
node.box,
|
||||||
|
node.maxLines ? "warn" : "error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// render-text-empty
|
||||||
|
if (node.render && ink.length === 0 && !(pageNo === 1 && node.id !== undefined && optionalFirst.has(node.id))) {
|
||||||
|
add("render-text-empty", `render-prop text ${label(node)} drew 0 lines (a pinned height, or a lineHeight on an ancestor?)`, node.box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// outside-safe-zone: text by its ink, everything else by its box.
|
||||||
|
const extent: Rect | null =
|
||||||
|
node.type === "text"
|
||||||
|
? (() => {
|
||||||
|
const rs = (node.lines ?? []).map((l) => inkRect(l, node)).filter((r) => r.w > 0);
|
||||||
|
if (rs.length === 0) return null;
|
||||||
|
const x0 = Math.min(...rs.map((r) => r.x));
|
||||||
|
const y0 = Math.min(...rs.map((r) => r.y));
|
||||||
|
return { x: x0, y: y0, w: Math.max(...rs.map(right)) - x0, h: Math.max(...rs.map(bottom)) - y0 };
|
||||||
|
})()
|
||||||
|
: node.box;
|
||||||
|
if (extent) {
|
||||||
|
// Chrome (fixed) may use the page edge; everything else stays inside the margins.
|
||||||
|
const zone = node.fixed ? pageRect : margin;
|
||||||
|
if (extent.x < zone.x - TOL || extent.y < zone.y - TOL || right(extent) > right(zone) + TOL || bottom(extent) > bottom(zone) + TOL) {
|
||||||
|
add("outside-safe-zone", `${label(node)} ${fmt(extent)} is outside ${node.fixed ? "the page" : "the margin box"} ${fmt(zone)}`, extent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepout: text lines, images and svg only (containers span their children).
|
||||||
|
for (const k of opts.keepOuts ?? []) {
|
||||||
|
if (k.page !== undefined && k.page !== pageNo) continue;
|
||||||
|
const parts: Rect[] =
|
||||||
|
node.type === "text" ? (node.lines ?? []).map((l) => inkRect(l, node)) : node.type === "image" || node.type === "svg" ? [node.box] : [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (intersects(part, k)) add("keepout", `${label(node)} ${fmt(part)} enters the keep-out ${fmt(k)}`, part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// below-wrap-area: the deepest non-chrome nodes that end under the content bottom.
|
||||||
|
const below = (n: LayoutNode): boolean => {
|
||||||
|
if (n.decor || n.fixed) return false;
|
||||||
|
const childBelow = n.children.map(below).some(Boolean);
|
||||||
|
const isBelow = bottom(n.box) > contentBottom + TOL;
|
||||||
|
if (isBelow && !childBelow) {
|
||||||
|
add("below-wrap-area", `${label(n)} ends at y ${bottom(n.box).toFixed(1)}, under the content bottom ${contentBottom.toFixed(1)}`, n.box);
|
||||||
|
}
|
||||||
|
return isBelow;
|
||||||
|
};
|
||||||
|
page.nodes.forEach(below);
|
||||||
|
|
||||||
|
// header-only-page
|
||||||
|
const hasHead = all.some((e) => e.node.id === headId);
|
||||||
|
const hasRow = all.some((e) => e.node.id?.startsWith(rowPrefix));
|
||||||
|
if (hasHead && !hasRow) add("header-only-page", "the only table content on this page is the repeated header row");
|
||||||
|
});
|
||||||
|
|
||||||
|
// split-block: a non-chrome id on two pages where it was authored wrap={false}.
|
||||||
|
const seen = new Map<string, { pages: Set<number>; noWrap: boolean; box: Rect }>();
|
||||||
|
layout.pages.forEach((page, idx) => {
|
||||||
|
walk(page.nodes, (n) => {
|
||||||
|
if (!n.id || n.fixed || n.decor || n.id === DECOR_ID) return;
|
||||||
|
const e = seen.get(n.id) ?? { pages: new Set<number>(), noWrap: false, box: n.box };
|
||||||
|
e.pages.add(idx + 1);
|
||||||
|
e.noWrap ||= !!n.noWrap;
|
||||||
|
seen.set(n.id, e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const [id, e] of seen) {
|
||||||
|
if (e.pages.size > 1 && e.noWrap) {
|
||||||
|
const pages = [...e.pages];
|
||||||
|
issues.push({ code: "split-block", severity: "error", page: pages[0], message: `wrap={false} block #${id} appears on pages ${pages.join(" and ")}`, box: e.box });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closing-not-at-bottom
|
||||||
|
if (opts.closingId && layout.pages.length > 0) {
|
||||||
|
const lastIdx = layout.pages.length - 1;
|
||||||
|
const last = layout.pages[lastIdx];
|
||||||
|
let found: LayoutNode | undefined;
|
||||||
|
walk(last.nodes, (n) => {
|
||||||
|
if (n.id === opts.closingId) found = n;
|
||||||
|
});
|
||||||
|
const want = last.height - pad.bottom;
|
||||||
|
if (!found) {
|
||||||
|
issues.push({ code: "closing-not-at-bottom", severity: "error", page: lastIdx + 1, message: `closing block #${opts.closingId} is not on the last page` });
|
||||||
|
} else if (Math.abs(bottom((found as LayoutNode).box) - want) > TOL) {
|
||||||
|
issues.push({
|
||||||
|
code: "closing-not-at-bottom",
|
||||||
|
severity: "error",
|
||||||
|
page: lastIdx + 1,
|
||||||
|
message: `closing block #${opts.closingId} ends at y ${bottom((found as LayoutNode).box).toFixed(1)}, the wrap area ends at ${want.toFixed(1)}`,
|
||||||
|
box: (found as LayoutNode).box,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { FILL, ColumnsError, cols, colsDetailed, pinned } from "./columns";
|
||||||
|
|
||||||
|
/** mulberry32, so failures reproduce. */
|
||||||
|
function prng(seed: number) {
|
||||||
|
return () => {
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const sumCents = (w: number[], gap: number) =>
|
||||||
|
Math.round(w.reduce((a, b) => a + b, 0) * 100) + Math.round(gap * 100) * (w.length - 1);
|
||||||
|
|
||||||
|
describe("cols", () => {
|
||||||
|
it("gives the fill column the remainder", () => {
|
||||||
|
expect(cols(500, [{ fixed: 30 }, { fill: true }, { fixed: 60 }, { fixed: 80 }])).toEqual([30, 330, 60, 80]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts gaps before allocating", () => {
|
||||||
|
const w = cols(500, [{ fixed: 100 }, { fill: true }, { fixed: 100 }], 10);
|
||||||
|
expect(w).toEqual([100, 280, 100]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allocates fractions of the space after gaps and puts the rounding on the fill", () => {
|
||||||
|
const w = cols(523.28, [{ frac: 1 / 3 }, { frac: 1 / 3 }, { fill: true }]);
|
||||||
|
expect(w.reduce((a, b) => a + b, 0)).toBeCloseTo(523.28, 10);
|
||||||
|
expect(w[0]).toBe(174.43);
|
||||||
|
expect(w[1]).toBe(174.43);
|
||||||
|
expect(w[2]).toBe(174.42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without a fill column the last fraction absorbs the remainder", () => {
|
||||||
|
const w = cols(100, [{ frac: 0.333 }, { frac: 0.333 }, { frac: 0.334 }]);
|
||||||
|
expect(sumCents(w, 0)).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums exactly for many random inputs", () => {
|
||||||
|
const rnd = prng(20260904);
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const total = Math.round((400 + rnd() * 300) * 100) / 100;
|
||||||
|
const gap = Math.round(rnd() * 12 * 100) / 100;
|
||||||
|
const n = 2 + Math.floor(rnd() * 5);
|
||||||
|
const specs: Parameters<typeof cols>[1] = [];
|
||||||
|
const fillAt = Math.floor(rnd() * n);
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
if (k === fillAt) specs.push({ fill: true });
|
||||||
|
else if (rnd() < 0.5) specs.push({ fixed: Math.round(rnd() * 30 * 100) / 100 });
|
||||||
|
else specs.push({ frac: Math.round(rnd() * 0.08 * 1000) / 1000 });
|
||||||
|
}
|
||||||
|
const w = cols(total, specs, gap);
|
||||||
|
expect(sumCents(w, gap)).toBe(Math.round(total * 100));
|
||||||
|
expect(w.every((x) => x >= 0)).toBe(true);
|
||||||
|
expect(w.every((x) => Math.abs(x * 100 - Math.round(x * 100)) < 1e-6)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports columns under their min so a Row can stack", () => {
|
||||||
|
const r = colsDetailed(300, [{ fixed: 170 }, { fill: true, min: 160 }], 10);
|
||||||
|
expect(r.widths).toEqual([170, 120]);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.short).toEqual([1]);
|
||||||
|
const ok = colsDetailed(400, [{ fixed: 170 }, { fill: true, min: 160 }], 10);
|
||||||
|
expect(ok.ok).toBe(true);
|
||||||
|
expect(ok.short).toEqual([]);
|
||||||
|
expect(colsDetailed(300, [{ frac: 0.2, min: 80 }, { fill: true }]).short).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws typed errors", () => {
|
||||||
|
const err = (fn: () => unknown) => {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const a = err(() => cols(100, [{ fixed: 80 }, { fixed: 30 }, { fill: true }]));
|
||||||
|
expect(a).toBeInstanceOf(ColumnsError);
|
||||||
|
expect((a as ColumnsError).code).toBe("fixed-exceeds-total");
|
||||||
|
expect((err(() => cols(100, [{ fixed: 95 }, { fill: true }], 10)) as ColumnsError).code).toBe("fixed-exceeds-total");
|
||||||
|
expect((err(() => cols(100, [{ fill: true }, { fill: true }])) as ColumnsError).code).toBe("multiple-fill");
|
||||||
|
expect((err(() => cols(100, [{ frac: 1.5 }])) as ColumnsError).code).toBe("invalid-spec");
|
||||||
|
expect((err(() => cols(-1, [{ fill: true }])) as ColumnsError).code).toBe("invalid-spec");
|
||||||
|
expect((err(() => cols(100, [{ frac: 0.8 }, { frac: 0.8 }, { fill: true }])) as ColumnsError).code).toBe("fixed-exceeds-total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a lone fill and exact-fit fixed columns", () => {
|
||||||
|
expect(cols(210, [{ fill: true }])).toEqual([210]);
|
||||||
|
expect(cols(100, [{ fixed: 40 }, { fill: true }, { fixed: 60 }])).toEqual([40, 0, 60]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("style helpers", () => {
|
||||||
|
it("pinned fixes the box on every axis", () => {
|
||||||
|
expect(pinned(42)).toEqual({ width: 42, minWidth: 42, maxWidth: 42 });
|
||||||
|
});
|
||||||
|
it("FILL pairs flexGrow with flexBasis 0", () => {
|
||||||
|
expect(FILL).toEqual({ flexGrow: 1, flexBasis: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
export type ColSpec =
|
||||||
|
| { fixed: number }
|
||||||
|
| { fill: true; min?: number }
|
||||||
|
| { frac: number; min?: number };
|
||||||
|
|
||||||
|
export class ColumnsError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly code: "fixed-exceeds-total" | "multiple-fill" | "invalid-spec",
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ColumnsError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColsResult {
|
||||||
|
/** pt widths in spec order; with gaps they sum exactly to the total (to 0.01). */
|
||||||
|
widths: number[];
|
||||||
|
/** True when every column with a `min` is at least that wide. */
|
||||||
|
ok: boolean;
|
||||||
|
/** Indexes of columns that fall under their `min`. */
|
||||||
|
short: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const to2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
// Work in hundredths of a point so the sum is exact integer arithmetic.
|
||||||
|
const cents = (n: number) => Math.round(n * 100);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocates `total` pt across the columns. Fixed columns keep their width, fractions take that share
|
||||||
|
* of the total, and the fill column (or, without one, the last fractional column) takes what is left
|
||||||
|
* so the widths (plus gaps) sum exactly to `total`. `gap` is the space between adjacent columns.
|
||||||
|
*/
|
||||||
|
export function colsDetailed(total: number, specs: ColSpec[], gap = 0): ColsResult {
|
||||||
|
if (!Number.isFinite(total) || total < 0) throw new ColumnsError(`invalid total ${total}`, "invalid-spec");
|
||||||
|
if (specs.filter((s) => "fill" in s).length > 1) {
|
||||||
|
throw new ColumnsError("at most one fill column is allowed", "multiple-fill");
|
||||||
|
}
|
||||||
|
const gaps = cents(gap) * Math.max(0, specs.length - 1);
|
||||||
|
const avail = cents(total) - gaps;
|
||||||
|
|
||||||
|
const widths = specs.map((spec) => {
|
||||||
|
if ("fixed" in spec) {
|
||||||
|
if (!(spec.fixed >= 0)) throw new ColumnsError(`invalid fixed width ${spec.fixed}`, "invalid-spec");
|
||||||
|
return cents(spec.fixed);
|
||||||
|
}
|
||||||
|
if ("frac" in spec) {
|
||||||
|
if (!(spec.frac >= 0 && spec.frac <= 1)) throw new ColumnsError(`invalid fraction ${spec.frac}`, "invalid-spec");
|
||||||
|
return Math.round(avail * spec.frac);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixedSum = specs.reduce((sum, spec, i) => ("fixed" in spec ? sum + widths[i] : sum), 0);
|
||||||
|
if (fixedSum + gaps > cents(total)) {
|
||||||
|
throw new ColumnsError(
|
||||||
|
`fixed columns (${to2(fixedSum / 100)} pt) plus gaps exceed the total ${total} pt`,
|
||||||
|
"fixed-exceeds-total",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remainder goes to the fill column; without one, to the last fractional column. A row of only
|
||||||
|
// fixed columns keeps its widths and may sum to less than the total.
|
||||||
|
let restIdx = specs.findIndex((s) => "fill" in s);
|
||||||
|
if (restIdx < 0) restIdx = specs.reduce((last, s, i) => ("frac" in s ? i : last), -1);
|
||||||
|
if (restIdx >= 0) {
|
||||||
|
const others = widths.reduce((sum, w, i) => (i === restIdx ? sum : sum + w), 0);
|
||||||
|
if (others > avail) throw new ColumnsError("fixed and fractional columns exceed the total", "fixed-exceeds-total");
|
||||||
|
widths[restIdx] = avail - others;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = widths.map((w) => to2(w / 100));
|
||||||
|
const short: number[] = [];
|
||||||
|
specs.forEach((spec, i) => {
|
||||||
|
const min = "min" in spec ? spec.min : undefined;
|
||||||
|
if (min !== undefined && out[i] + 1e-9 < min) short.push(i);
|
||||||
|
});
|
||||||
|
return { widths: out, ok: short.length === 0, short };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just the widths; see colsDetailed for the `ok` flag a Row uses to decide whether to stack. */
|
||||||
|
export function cols(total: number, specs: ColSpec[], gap = 0): number[] {
|
||||||
|
return colsDetailed(total, specs, gap).widths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A box that cannot grow or shrink: every flex-row child except the fill column uses this. */
|
||||||
|
export const pinned = (w: number) => ({ width: w, minWidth: w, maxWidth: w });
|
||||||
|
|
||||||
|
/** The verified-safe fill child: flexGrow alone leaves stale line breaks after the flex shrink. */
|
||||||
|
export const FILL = { flexGrow: 1, flexBasis: 0 } as const;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type PlanErrorCode = "tail-too-tall" | "chrome-reserve" | "invalid-plan";
|
||||||
|
|
||||||
|
/** A layout plan that cannot work (content that can never fit), reported before rendering. */
|
||||||
|
export class PlanError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly code: PlanErrorCode,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PlanError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { walk, type LayoutNode, type LayoutPage } from "./layoutTree";
|
||||||
|
|
||||||
|
const r1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical text that gets hashed: page sizes, every text node's box (rounded to 0.1 pt) and its
|
||||||
|
* line strings, and image boxes. Never the raw PDF bytes: Creator/Producer feed the PDF /ID, so the
|
||||||
|
* bytes change with every app update even when the pages do not.
|
||||||
|
*/
|
||||||
|
export function canonicalLayout(layout: { pages: LayoutPage[] }): string {
|
||||||
|
const pages = layout.pages.map((p) => {
|
||||||
|
const items: unknown[] = [];
|
||||||
|
walk(p.nodes, (n: LayoutNode) => {
|
||||||
|
if (n.type === "text") items.push(["t", r1(n.box.x), r1(n.box.y), r1(n.box.w), r1(n.box.h), (n.lines ?? []).map((l) => l.text)]);
|
||||||
|
else if (n.type === "image") items.push(["i", r1(n.box.x), r1(n.box.y), r1(n.box.w), r1(n.box.h)]);
|
||||||
|
});
|
||||||
|
return [r1(p.width), r1(p.height), items];
|
||||||
|
});
|
||||||
|
return JSON.stringify(pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const K = new Uint32Array([
|
||||||
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||||
|
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||||
|
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||||
|
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||||
|
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||||
|
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Plain-JS SHA-256 so the fingerprint is identical where crypto.subtle is missing (insecure contexts). */
|
||||||
|
export function sha256HexJs(bytes: Uint8Array): string {
|
||||||
|
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
||||||
|
const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6);
|
||||||
|
padded.set(bytes);
|
||||||
|
padded[bytes.length] = 0x80;
|
||||||
|
const view = new DataView(padded.buffer);
|
||||||
|
view.setUint32(padded.length - 8, Math.floor((bytes.length * 8) / 2 ** 32));
|
||||||
|
view.setUint32(padded.length - 4, (bytes.length * 8) >>> 0);
|
||||||
|
const w = new Uint32Array(64);
|
||||||
|
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||||
|
for (let off = 0; off < padded.length; off += 64) {
|
||||||
|
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
|
||||||
|
for (let i = 16; i < 64; i++) {
|
||||||
|
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||||
|
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||||
|
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
||||||
|
}
|
||||||
|
let [a, b, c, d, e, f, g, hh] = h;
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0;
|
||||||
|
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0;
|
||||||
|
hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
|
||||||
|
}
|
||||||
|
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
|
||||||
|
}
|
||||||
|
return [...h].map((x) => x.toString(16).padStart(8, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 hex of a string: crypto.subtle when present, else the JS fallback. Always async. */
|
||||||
|
export async function sha256Hex(text: string): Promise<string> {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const subtle = globalThis.crypto?.subtle;
|
||||||
|
if (subtle) {
|
||||||
|
const digest = await subtle.digest("SHA-256", bytes);
|
||||||
|
return [...new Uint8Array(digest)].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
return sha256HexJs(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function layoutFingerprint(layout: { pages: LayoutPage[] }): Promise<string> {
|
||||||
|
return sha256Hex(canonicalLayout(layout));
|
||||||
|
}
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
// Minimal typings for the parts of fontkit 2.0.4 the measurer uses (the package ships none).
|
||||||
|
declare module "fontkit" {
|
||||||
|
export interface GlyphPosition {
|
||||||
|
xAdvance: number;
|
||||||
|
}
|
||||||
|
export interface GlyphRun {
|
||||||
|
glyphs: unknown[];
|
||||||
|
positions: GlyphPosition[];
|
||||||
|
}
|
||||||
|
export interface Font {
|
||||||
|
unitsPerEm: number;
|
||||||
|
hasGlyphForCodePoint(codePoint: number): boolean;
|
||||||
|
layout(text: string, features?: unknown, script?: string, language?: string, direction?: string): GlyphRun;
|
||||||
|
}
|
||||||
|
export function create(data: Uint8Array): Font;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
MARGIN_PRESETS,
|
||||||
|
PAGE_SIZES,
|
||||||
|
PRINTER_SAFE_MARGIN,
|
||||||
|
computeFrame,
|
||||||
|
effectiveMargins,
|
||||||
|
type Sides,
|
||||||
|
} from "./geometry";
|
||||||
|
|
||||||
|
const sides = (top: number, right: number, bottom: number, left: number): Sides => ({ top, right, bottom, left });
|
||||||
|
|
||||||
|
describe("PAGE_SIZES", () => {
|
||||||
|
it("has exact A4 and Letter", () => {
|
||||||
|
expect(PAGE_SIZES.A4).toMatchObject({ width: 595.28, height: 841.89, size: { width: "210mm", height: "297mm" } });
|
||||||
|
expect(PAGE_SIZES.LETTER).toMatchObject({ width: 612, height: 792, size: "LETTER" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MARGIN_PRESETS", () => {
|
||||||
|
it("narrow is 0.5 in, normal is 0.75 in, template defers", () => {
|
||||||
|
expect(MARGIN_PRESETS.narrow).toEqual(sides(36, 36, 36, 36));
|
||||||
|
expect(MARGIN_PRESETS.normal).toEqual(sides(54, 54, 54, 54));
|
||||||
|
expect(MARGIN_PRESETS.template).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("effectiveMargins", () => {
|
||||||
|
const linea = sides(56, 77.4, 60, 77.4);
|
||||||
|
it("template preset returns the template values", () => {
|
||||||
|
expect(effectiveMargins("template", linea)).toEqual(linea);
|
||||||
|
});
|
||||||
|
it("takes the per-side maximum of choice and template floor", () => {
|
||||||
|
expect(effectiveMargins("narrow", linea)).toEqual(linea);
|
||||||
|
expect(effectiveMargins("normal", linea)).toEqual(sides(56, 77.4, 60, 77.4));
|
||||||
|
expect(effectiveMargins("normal", sides(20, 20, 20, 20))).toEqual(sides(54, 54, 54, 54));
|
||||||
|
expect(effectiveMargins("narrow", sides(20, 50, 20, 20))).toEqual(sides(36, 50, 36, 36));
|
||||||
|
});
|
||||||
|
it("never goes under the printer-safe floor", () => {
|
||||||
|
expect(effectiveMargins(sides(0, 5, 10, 17.9), sides(0, 0, 0, 0))).toEqual(sides(18, 18, 18, 18));
|
||||||
|
expect(PRINTER_SAFE_MARGIN).toBe(18);
|
||||||
|
expect(effectiveMargins("template", sides(10, 10, 10, 10))).toEqual(sides(18, 18, 18, 18));
|
||||||
|
});
|
||||||
|
it("accepts custom sides", () => {
|
||||||
|
expect(effectiveMargins(sides(40, 30, 20, 25), sides(10, 10, 10, 10))).toEqual(sides(40, 30, 20, 25));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeFrame", () => {
|
||||||
|
const base = { footerReserve: 0, continuationReserve: 0, letterMode: false };
|
||||||
|
|
||||||
|
it("A4 with narrow margins", () => {
|
||||||
|
const f = computeFrame({ ...base, page: PAGE_SIZES.A4, margins: MARGIN_PRESETS.narrow! });
|
||||||
|
expect(f.contentWidth).toBeCloseTo(523.28, 2);
|
||||||
|
expect(f.contentHeight).toBeCloseTo(769.89, 2);
|
||||||
|
expect(f.widthGain).toBe(0);
|
||||||
|
expect(f.heightDeficit).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("A4 with normal margins and reserves", () => {
|
||||||
|
const f = computeFrame({
|
||||||
|
page: PAGE_SIZES.A4,
|
||||||
|
margins: MARGIN_PRESETS.normal!,
|
||||||
|
footerReserve: 20,
|
||||||
|
continuationReserve: 24,
|
||||||
|
letterMode: false,
|
||||||
|
});
|
||||||
|
expect(f.pad).toEqual(sides(54, 54, 74, 54));
|
||||||
|
expect(f.contentWidth).toBeCloseTo(487.28, 2);
|
||||||
|
expect(f.contentHeight).toBeCloseTo(713.89, 2);
|
||||||
|
expect(f.footerReserve).toBe(20);
|
||||||
|
expect(f.continuationReserve).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Letter keeps the side margins: extra width to content, deficit reported", () => {
|
||||||
|
const margins = MARGIN_PRESETS.normal!;
|
||||||
|
const a4 = computeFrame({ ...base, page: PAGE_SIZES.A4, margins, letterMode: true });
|
||||||
|
const letter = computeFrame({ ...base, page: PAGE_SIZES.LETTER, margins, letterMode: true });
|
||||||
|
expect(letter.pad).toEqual(a4.pad);
|
||||||
|
expect(letter.contentWidth - a4.contentWidth).toBeCloseTo(16.72, 2);
|
||||||
|
expect(letter.widthGain).toBeCloseTo(16.72, 2);
|
||||||
|
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
||||||
|
expect(a4.contentHeight - letter.contentHeight).toBeCloseTo(49.89, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every preset on both pages yields positive, consistent boxes", () => {
|
||||||
|
for (const page of Object.values(PAGE_SIZES)) {
|
||||||
|
for (const m of [MARGIN_PRESETS.narrow!, MARGIN_PRESETS.normal!, sides(56, 77.4, 60, 77.4), sides(18, 18, 18, 18)]) {
|
||||||
|
const f = computeFrame({ ...base, page, margins: m, footerReserve: 14 });
|
||||||
|
expect(f.contentWidth + f.pad.left + f.pad.right).toBeCloseTo(page.width, 1);
|
||||||
|
expect(f.contentHeight + f.pad.top + f.pad.bottom).toBeCloseTo(page.height, 1);
|
||||||
|
expect(f.contentWidth).toBeGreaterThan(0);
|
||||||
|
expect(f.contentHeight).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report a deficit when letter mode is off", () => {
|
||||||
|
const f = computeFrame({ ...base, page: PAGE_SIZES.LETTER, margins: MARGIN_PRESETS.narrow! });
|
||||||
|
expect(f.heightDeficit).toBe(0);
|
||||||
|
expect(f.widthGain).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export type PageId = "A4" | "LETTER";
|
||||||
|
|
||||||
|
export interface PageSpec {
|
||||||
|
id: PageId;
|
||||||
|
/** Physical size in pt. */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Value for react-pdf's Page `size` (mm strings give A4 exactly; verified). */
|
||||||
|
size: { width: string; height: string } | "LETTER";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAGE_SIZES: Record<PageId, PageSpec> = {
|
||||||
|
A4: { id: "A4", width: 595.28, height: 841.89, size: { width: "210mm", height: "297mm" } },
|
||||||
|
LETTER: { id: "LETTER", width: 612, height: 792, size: "LETTER" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Sides {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarginPreset = "template" | "narrow" | "normal";
|
||||||
|
|
||||||
|
/** Printer-safe floor: most office printers cannot print closer than this to the edge. */
|
||||||
|
export const PRINTER_SAFE_MARGIN = 18;
|
||||||
|
|
||||||
|
const all = (v: number): Sides => ({ top: v, right: v, bottom: v, left: v });
|
||||||
|
|
||||||
|
/** `template` takes its value from the template, so it has no fixed sides here. */
|
||||||
|
export const MARGIN_PRESETS: Record<MarginPreset, Sides | null> = {
|
||||||
|
template: null,
|
||||||
|
narrow: all(36),
|
||||||
|
normal: all(54),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarginChoice = MarginPreset | Sides;
|
||||||
|
|
||||||
|
/** Per-side max(chosen, template floor, printer-safe floor). */
|
||||||
|
export function effectiveMargins(chosen: Sides | MarginPreset, templateFloor: Sides): Sides {
|
||||||
|
const preset = typeof chosen === "string" ? MARGIN_PRESETS[chosen] : chosen;
|
||||||
|
// "template" means: use the template's own margins.
|
||||||
|
const base = preset ?? templateFloor;
|
||||||
|
const side = (k: keyof Sides) => Math.max(base[k], templateFloor[k], PRINTER_SAFE_MARGIN);
|
||||||
|
return { top: side("top"), right: side("right"), bottom: side("bottom"), left: side("left") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameInput {
|
||||||
|
page: PageSpec;
|
||||||
|
/** Effective margins (already through effectiveMargins). */
|
||||||
|
margins: Sides;
|
||||||
|
/** Height reserved at the bottom of every page for fixed footer chrome. */
|
||||||
|
footerReserve: number;
|
||||||
|
/** Height reserved at the top of pages 2+ for the continuation band. */
|
||||||
|
continuationReserve: number;
|
||||||
|
letterMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageFrame {
|
||||||
|
page: PageSpec;
|
||||||
|
pad: Sides;
|
||||||
|
contentWidth: number;
|
||||||
|
/** Wrap area height on page 1: page height - top and bottom padding. */
|
||||||
|
contentHeight: number;
|
||||||
|
continuationReserve: number;
|
||||||
|
footerReserve: number;
|
||||||
|
/** Extra content width over A4 with the same side margins (Letter only; 0 for A4). */
|
||||||
|
widthGain: number;
|
||||||
|
/** Height missing against A4 with the same margins (Letter only; 0 for A4). Templates absorb it from elastic gaps first. */
|
||||||
|
heightDeficit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
export function computeFrame(input: FrameInput): PageFrame {
|
||||||
|
const { page, margins, footerReserve, continuationReserve, letterMode } = input;
|
||||||
|
const a4 = PAGE_SIZES.A4;
|
||||||
|
// The padding is the physical margin plus the footer chrome that lives inside the wrap area.
|
||||||
|
const pad: Sides = { ...margins, bottom: margins.bottom + footerReserve };
|
||||||
|
const contentWidth = round2(page.width - pad.left - pad.right);
|
||||||
|
const contentHeight = round2(page.height - pad.top - pad.bottom);
|
||||||
|
const compress = letterMode && page.id === "LETTER";
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pad,
|
||||||
|
contentWidth,
|
||||||
|
contentHeight,
|
||||||
|
continuationReserve,
|
||||||
|
footerReserve,
|
||||||
|
widthGain: compress ? round2(page.width - a4.width) : 0,
|
||||||
|
heightDeficit: compress ? round2(a4.height - page.height) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Ids the audit recognises. react-pdf copies `id` into the layout tree, so these survive to
|
||||||
|
* auditLayout without any custom prop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Marks decor (absolute decorative boxes). The audit ignores a decor node and everything under it. */
|
||||||
|
export const DECOR_ID = "decor";
|
||||||
|
|
||||||
|
/** The continuation band text: legitimately draws 0 lines on page 1. */
|
||||||
|
export const BAND_ID = "chrome-band";
|
||||||
|
|
||||||
|
/** The "Page n of N" label. */
|
||||||
|
export const PAGE_LABEL_ID = "chrome-page";
|
||||||
|
|
||||||
|
/** The repeated table header row (fixed). */
|
||||||
|
export const TABLE_HEAD_ID = "tbl-head";
|
||||||
|
|
||||||
|
/** Body rows are `tbl-row-<index>`: unique, so a row on two pages is visible as a split. */
|
||||||
|
export const TABLE_ROW_PREFIX = "tbl-row-";
|
||||||
|
|
||||||
|
/** The closing block (bank + signatory): the audit expects its bottom on the wrap-area bottom of the last page. */
|
||||||
|
export const CLOSING_ID = "closing";
|
||||||
|
|
||||||
|
/** The totals block (subtotal ... amount in words): never split across pages. */
|
||||||
|
export const TOTALS_ID = "totals";
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Normalised layout tree. react-pdf 4.9.0 hands `Document onRender` the whole laid-out tree as
|
||||||
|
* `_INTERNAL__LAYOUT__DATA_`; this module turns it into a small typed structure the audit and the
|
||||||
|
* fingerprint read, so nothing else depends on react-pdf internals.
|
||||||
|
*
|
||||||
|
* The internal tree (verified by dumping real renders, layout 5.2.0 / textkit 7.0.1):
|
||||||
|
*
|
||||||
|
* DOCUMENT { props, children: PAGE[] }
|
||||||
|
* PAGE { box: { width, height, top: 0, left: 0, padding* ... }, children } one per physical page
|
||||||
|
* VIEW | TEXT | IMAGE | SVG | LINK | ... { type, props, style, box, children, wasSplit, origin }
|
||||||
|
* TEXT also has `lines: Line[]`; its children are TEXT_INSTANCE { value } (the source strings)
|
||||||
|
* Line { string, box: { x, y, width, height }, xAdvance, height, ascent, overflowRight, runs }
|
||||||
|
*
|
||||||
|
* - `box.top` / `box.left` are relative to the PARENT's border box (Yoga). Absolute page coordinates are
|
||||||
|
* the sum down the chain; `origin` is the transform centre, not a position.
|
||||||
|
* - `box.width` / `box.height` are the final sizes after flex. `style.lineHeight` of a static Text with
|
||||||
|
* an 'Npt' value is rewritten to N*1000 (13.8pt -> 13800): read line boxes, never the style.
|
||||||
|
* - Line `box.x` is the aligned start of the line inside the text content box; `box.y` stacks lines
|
||||||
|
* from the top. `box.width` is NOT the ink width (it spans the container plus the overflow). The
|
||||||
|
* advance is `xAdvance`: glyph advances including the letterSpacing after the last glyph and any
|
||||||
|
* leading/trailing whitespace, which `overflowLeft`/`overflowRight` account for. Ink width is
|
||||||
|
* `xAdvance - overflowLeft - overflowRight` (minus the trailing letterSpacing step).
|
||||||
|
* - `props` keeps `id`, `fixed`, `wrap`, `minPresenceAhead` and `render` as the author wrote them. A node
|
||||||
|
* that the paginator moved to the next page gets `wrap: true, break: false` written over its props.
|
||||||
|
* - Fixed nodes are copied onto every page; each page has its own PAGE node with only the pieces on it.
|
||||||
|
* - SVG children (paths, groups) carry no layout boxes; the SVG node is a leaf here.
|
||||||
|
*
|
||||||
|
* Normalised form: every node has an ABSOLUTE page-space box and a lower-case `type`. TEXT_INSTANCE nodes
|
||||||
|
* disappear into the owning text's `text` (the source string, textTransform applied). Text lines get
|
||||||
|
* absolute boxes whose width is the advance width without edge whitespace. `decor` is inherited from any DECOR_ID ancestor.
|
||||||
|
*/
|
||||||
|
import { DECOR_ID } from "./ids";
|
||||||
|
|
||||||
|
export interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutLine extends Rect {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutNodeType = "view" | "text" | "image" | "svg" | "link" | "other";
|
||||||
|
|
||||||
|
export interface LayoutNode {
|
||||||
|
type: LayoutNodeType;
|
||||||
|
id?: string;
|
||||||
|
/** Absolute page-space border box. */
|
||||||
|
box: Rect;
|
||||||
|
/** Text nodes: the source string (what should have been drawn). Render-prop text: the drawn string. */
|
||||||
|
text?: string;
|
||||||
|
lines?: LayoutLine[];
|
||||||
|
/** `fixed` itself or under a fixed node (page chrome that repeats on every page). */
|
||||||
|
fixed?: boolean;
|
||||||
|
/** Authored `wrap={false}`. */
|
||||||
|
noWrap?: boolean;
|
||||||
|
/** Under a DECOR_ID node (or itself one). */
|
||||||
|
decor?: boolean;
|
||||||
|
/** position: absolute. */
|
||||||
|
abs?: boolean;
|
||||||
|
/** Text with an authored maxLines clamp (truncation is intended). */
|
||||||
|
maxLines?: number;
|
||||||
|
/** Text drawn from a render prop. */
|
||||||
|
render?: boolean;
|
||||||
|
/** letterSpacing in pt (the trailing step after the last glyph is not ink). */
|
||||||
|
letterSpacing?: number;
|
||||||
|
children: LayoutNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutPage {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
nodes: LayoutNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset of react-pdf's internal node this module reads. */
|
||||||
|
interface RawNode {
|
||||||
|
type: string;
|
||||||
|
value?: string;
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
box?: Record<string, number | undefined>;
|
||||||
|
lines?: RawLine[];
|
||||||
|
children?: RawNode[];
|
||||||
|
}
|
||||||
|
interface RawLine {
|
||||||
|
string: string;
|
||||||
|
box: { x: number; y: number; width: number; height: number };
|
||||||
|
xAdvance: number;
|
||||||
|
height: number;
|
||||||
|
overflowLeft?: number;
|
||||||
|
overflowRight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPES: Record<string, LayoutNodeType> = {
|
||||||
|
VIEW: "view",
|
||||||
|
TEXT: "text",
|
||||||
|
TSPAN: "text",
|
||||||
|
IMAGE: "image",
|
||||||
|
SVG: "svg",
|
||||||
|
LINK: "link",
|
||||||
|
};
|
||||||
|
|
||||||
|
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||||
|
|
||||||
|
function collectText(node: RawNode): string {
|
||||||
|
if (node.type === "TEXT_INSTANCE") return node.value ?? "";
|
||||||
|
return (node.children ?? []).map(collectText).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function convert(
|
||||||
|
node: RawNode,
|
||||||
|
parent: { x: number; y: number },
|
||||||
|
parentDecor: boolean,
|
||||||
|
parentIsText: boolean,
|
||||||
|
parentFixed: boolean,
|
||||||
|
): LayoutNode | null {
|
||||||
|
if (node.type === "TEXT_INSTANCE") return null;
|
||||||
|
const b = node.box ?? {};
|
||||||
|
const x = parent.x + num(b.left);
|
||||||
|
const y = parent.y + num(b.top);
|
||||||
|
const type = TYPES[node.type] ?? "other";
|
||||||
|
// A Text nested in a Text is folded into the parent's attributed string and has no lines of its own.
|
||||||
|
if (type === "text" && parentIsText && !node.lines) return null;
|
||||||
|
|
||||||
|
const props = node.props ?? {};
|
||||||
|
const id = typeof props.id === "string" ? props.id : undefined;
|
||||||
|
const decor = parentDecor || id === DECOR_ID;
|
||||||
|
const out: LayoutNode = {
|
||||||
|
type,
|
||||||
|
...(id !== undefined ? { id } : {}),
|
||||||
|
box: { x, y, w: num(b.width), h: num(b.height) },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
// Everything under a fixed node is chrome too (it is the fixed node that repeats).
|
||||||
|
const fixed = parentFixed || props.fixed === true;
|
||||||
|
if (fixed) out.fixed = true;
|
||||||
|
if (props.wrap === false) out.noWrap = true;
|
||||||
|
if (decor) out.decor = true;
|
||||||
|
if (node.style?.position === "absolute") out.abs = true;
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
const ls = num(node.style?.letterSpacing);
|
||||||
|
if (ls) out.letterSpacing = ls;
|
||||||
|
const maxLines = num(node.style?.maxLines);
|
||||||
|
if (maxLines) out.maxLines = maxLines;
|
||||||
|
const isRender = typeof props.render === "function";
|
||||||
|
if (isRender) out.render = true;
|
||||||
|
const padL = num(b.paddingLeft) + num(b.borderLeftWidth);
|
||||||
|
const padT = num(b.paddingTop) + num(b.borderTopWidth);
|
||||||
|
// `xAdvance` and `box.width` include the line's leading/trailing whitespace (`overflowLeft/Right`);
|
||||||
|
// the ink excludes it. Line 3 of a wrapped address can be 182.36 pt "wide" in a 180 pt box with
|
||||||
|
// overflowRight 2.36: that is just the trailing space.
|
||||||
|
out.lines = (node.lines ?? []).map((l) => ({
|
||||||
|
x: x + padL + num(l.box.x) + num(l.overflowLeft),
|
||||||
|
y: y + padT + num(l.box.y),
|
||||||
|
w: Math.max(0, num(l.xAdvance) - num(l.overflowLeft) - num(l.overflowRight)),
|
||||||
|
h: num(l.box.height) || num(l.height),
|
||||||
|
text: l.string,
|
||||||
|
}));
|
||||||
|
if (isRender) out.text = out.lines.map((l) => l.text).join(" ");
|
||||||
|
else {
|
||||||
|
const raw = collectText(node);
|
||||||
|
out.text = node.style?.textTransform === "uppercase" ? raw.toUpperCase() : raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Svg internals have no boxes; keep the Svg a leaf.
|
||||||
|
if (type !== "svg") {
|
||||||
|
for (const child of node.children ?? []) {
|
||||||
|
const c = convert(child, { x, y }, decor, type === "text", fixed);
|
||||||
|
if (c) out.children.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts react-pdf's internal layout tree (the `_INTERNAL__LAYOUT__DATA_` of onRender). */
|
||||||
|
export function normalizeLayout(layoutData: unknown): { pages: LayoutPage[] } {
|
||||||
|
const doc = layoutData as RawNode | undefined;
|
||||||
|
if (!doc || !Array.isArray(doc.children)) throw new Error("normalizeLayout: not a react-pdf layout tree");
|
||||||
|
const pages: LayoutPage[] = doc.children
|
||||||
|
.filter((p) => p.type === "PAGE")
|
||||||
|
.map((p) => {
|
||||||
|
const b = p.box ?? {};
|
||||||
|
const nodes: LayoutNode[] = [];
|
||||||
|
for (const child of p.children ?? []) {
|
||||||
|
const c = convert(child, { x: 0, y: 0 }, false, false, false);
|
||||||
|
if (c) nodes.push(c);
|
||||||
|
}
|
||||||
|
return { width: num(b.width), height: num(b.height), nodes };
|
||||||
|
});
|
||||||
|
return { pages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first walk of a page's nodes. `parent` is the enclosing node (undefined for top level). */
|
||||||
|
export function walk(nodes: LayoutNode[], visit: (n: LayoutNode, parent: LayoutNode | undefined) => void, parent?: LayoutNode): void {
|
||||||
|
for (const n of nodes) {
|
||||||
|
visit(n, parent);
|
||||||
|
walk(n.children, visit, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user