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/styles": "^1.116.0",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@napi-rs/canvas": "*",
|
||||
"@react-pdf/renderer": "^4.9.0",
|
||||
"@tauri-apps/api": "^2.12.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||
"fontkit": "2.0.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-is": "^19.3.0"
|
||||
|
||||
+4
-1
@@ -14,7 +14,9 @@
|
||||
"app:dev": "tauri dev",
|
||||
"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: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": {
|
||||
"@carbon/icons-react": "^11.89.0",
|
||||
@@ -25,6 +27,7 @@
|
||||
"@tauri-apps/api": "^2.12.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||
"fontkit": "2.0.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^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 = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"percent-encoding",
|
||||
"rusqlite",
|
||||
"rusqlite_migration",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
|
||||
@@ -26,6 +26,8 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2"
|
||||
|
||||
# Desktop-only plugins (not available on mobile targets).
|
||||
[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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -248,9 +232,4 @@ mod tests {
|
||||
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
||||
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,
|
||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_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> {
|
||||
Ok(Invoice {
|
||||
@@ -55,6 +55,9 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||
cancelled_at: row.get(31)?,
|
||||
cancel_reason: row.get(32)?,
|
||||
archived_pdf_sha256: row.get(33)?,
|
||||
render_prefs: row.get(34)?,
|
||||
archived_fingerprint: row.get(35)?,
|
||||
archived_at: row.get(36)?,
|
||||
items: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -922,6 +925,7 @@ mod tests {
|
||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
||||
assert_eq!(inv.render_prefs, prefs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod archive;
|
||||
pub mod assets;
|
||||
pub mod clients;
|
||||
pub mod files;
|
||||
pub mod invoice;
|
||||
pub mod raw;
|
||||
pub mod series;
|
||||
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,
|
||||
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,
|
||||
signatory_designation";
|
||||
signatory_designation, render_prefs, last_export_dir";
|
||||
|
||||
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||
Ok(Settings {
|
||||
@@ -30,6 +30,8 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||
default_hsn_sac: row.get(17)?,
|
||||
signatory_name: row.get(18)?,
|
||||
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,
|
||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||
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"#,
|
||||
params![
|
||||
settings.vendor_name,
|
||||
@@ -77,6 +79,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
||||
settings.default_hsn_sac,
|
||||
settings.signatory_name,
|
||||
settings.signatory_designation,
|
||||
settings.render_prefs,
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -230,6 +233,26 @@ mod tests {
|
||||
(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]
|
||||
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
||||
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";
|
||||
|
||||
/// 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)]
|
||||
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> {
|
||||
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.
|
||||
@@ -536,6 +548,43 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
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) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||
|
||||
+25
-1
@@ -2,6 +2,7 @@ mod commands;
|
||||
mod db;
|
||||
mod gst;
|
||||
mod models;
|
||||
mod selftest;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -12,6 +13,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Connection>,
|
||||
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
|
||||
@@ -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));
|
||||
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")) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
@@ -54,6 +69,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
||||
Ok(AppState {
|
||||
db: Mutex::new(conn),
|
||||
data_dir,
|
||||
local_data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,7 +129,15 @@ pub fn run() {
|
||||
commands::assets::save_asset_bytes,
|
||||
commands::assets::read_asset_data_uri,
|
||||
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!());
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct Settings {
|
||||
pub default_hsn_sac: String,
|
||||
pub signatory_name: 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)]
|
||||
@@ -194,6 +200,10 @@ pub struct Invoice {
|
||||
pub cancelled_at: Option<String>,
|
||||
pub cancel_reason: 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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
} from "./types";
|
||||
|
||||
export interface ArchiveStatus {
|
||||
archived: boolean;
|
||||
sha256?: string;
|
||||
fingerprint?: string;
|
||||
archivedAt?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSettings: () => invoke<Settings>("get_settings"),
|
||||
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
||||
@@ -28,7 +35,7 @@ export const api = {
|
||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||
|
||||
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 }),
|
||||
cancelInvoice: (id: number, reason: string) =>
|
||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||
@@ -47,6 +54,19 @@ export const api = {
|
||||
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
||||
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { 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`;
|
||||
}
|
||||
+57
-93
@@ -1,108 +1,72 @@
|
||||
import { pdf } from "@react-pdf/renderer";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { registerPdfFonts } from "../pdf/fonts";
|
||||
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||
import pkg from "../../package.json";
|
||||
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
|
||||
import { vendorFromSnapshot } from "../pdf/model/vendor";
|
||||
import { Superseded } from "../pdf/render/client";
|
||||
import type { RenderResult } from "../pdf/render/jobs";
|
||||
import { api } from "./api";
|
||||
import { imageSizeFromDataUri } from "./imageSize";
|
||||
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||
import type { BankAccount, Invoice, Settings } from "./types";
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
|
||||
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
|
||||
|
||||
function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
|
||||
if (!snapshot) return live;
|
||||
try {
|
||||
return { ...live, ...(JSON.parse(snapshot) as Partial<Settings>) };
|
||||
} catch {
|
||||
return live;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPdfProps(
|
||||
/**
|
||||
* The render model for an invoice whose images are already loaded (the live preview caches them).
|
||||
* Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback.
|
||||
*/
|
||||
export function modelFromAssets(
|
||||
invoice: Invoice,
|
||||
settings: Settings,
|
||||
bank: BankAccount | null,
|
||||
): Promise<InvoicePdfProps> {
|
||||
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
|
||||
// legacy rows without a snapshot.
|
||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
||||
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
||||
const signatureDataUri = invoice.signaturePath
|
||||
? await api.readAssetDataUri(invoice.signaturePath)
|
||||
: null;
|
||||
|
||||
let resolvedBank = bank;
|
||||
if (!resolvedBank && invoice.bankSnapshot) {
|
||||
try {
|
||||
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
|
||||
} catch {
|
||||
resolvedBank = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vendor,
|
||||
bank: resolvedBank,
|
||||
logoDataUri,
|
||||
logoUri: string | null,
|
||||
signatureDataUri: string | null,
|
||||
): RenderModel {
|
||||
return buildRenderModel({
|
||||
invoice,
|
||||
vendor: settings,
|
||||
bank,
|
||||
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
|
||||
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"] }],
|
||||
appVersion: pkg.version,
|
||||
});
|
||||
if (!path) return null;
|
||||
await api.saveBinaryFile(path, dataBase64);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function exportInvoicePdf(
|
||||
/** Loads the invoice's logo and signature, then builds its render model. */
|
||||
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,
|
||||
): Promise<string | null> {
|
||||
const props = await buildPdfProps(invoice, settings, bank);
|
||||
return exportPdfFromProps(props, invoice.number);
|
||||
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 {
|
||||
return await getRenderClient().render({ id: nextRenderJobId(), model, prefs });
|
||||
} catch (err) {
|
||||
if (!(err instanceof Superseded) || attempt >= 4) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
signatoryName: 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 {
|
||||
@@ -132,6 +136,11 @@ export interface Invoice {
|
||||
cancelledAt: string | null;
|
||||
cancelReason: string;
|
||||
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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/carbon.scss";
|
||||
import { maybeRunSelfTest } from "./selftestBoot";
|
||||
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</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