diff --git a/package-lock.json b/package-lock.json index bddd2aa..f5dc1ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@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", @@ -27,6 +26,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", + "fontkit": "2.0.4", "pdfjs-dist": "6.4.299", "sass": "^1.105.1", "typescript": "^5.9.3", diff --git a/package.json b/package.json index 7389800..90026bf 100644 --- a/package.json +++ b/package.json @@ -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", @@ -35,6 +37,7 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", + "fontkit": "2.0.4", "pdfjs-dist": "6.4.299", "sass": "^1.105.1", "typescript": "^5.9.3", diff --git a/public/fonts/ibm-plex-mono/IBMPlexMono-Italic.ttf b/public/fonts/ibm-plex-mono/IBMPlexMono-Italic.ttf new file mode 100644 index 0000000..9721b32 Binary files /dev/null and b/public/fonts/ibm-plex-mono/IBMPlexMono-Italic.ttf differ diff --git a/public/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf b/public/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000..f730099 Binary files /dev/null and b/public/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf differ diff --git a/public/fonts/ibm-plex-mono/IBMPlexMono-SemiBold.ttf b/public/fonts/ibm-plex-mono/IBMPlexMono-SemiBold.ttf new file mode 100644 index 0000000..58ef401 Binary files /dev/null and b/public/fonts/ibm-plex-mono/IBMPlexMono-SemiBold.ttf differ diff --git a/public/fonts/ibm-plex-mono/OFL.txt b/public/fonts/ibm-plex-mono/OFL.txt new file mode 100755 index 0000000..c35c4c6 --- /dev/null +++ b/public/fonts/ibm-plex-mono/OFL.txt @@ -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. diff --git a/public/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf b/public/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf new file mode 100644 index 0000000..1d66b1a Binary files /dev/null and b/public/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf differ diff --git a/public/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf b/public/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf new file mode 100644 index 0000000..efe8a1f Binary files /dev/null and b/public/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf differ diff --git a/public/fonts/ibm-plex-sans/IBMPlexSans-Light.ttf b/public/fonts/ibm-plex-sans/IBMPlexSans-Light.ttf new file mode 100644 index 0000000..6229292 Binary files /dev/null and b/public/fonts/ibm-plex-sans/IBMPlexSans-Light.ttf differ diff --git a/public/fonts/ibm-plex-sans/IBMPlexSans-Medium.ttf b/public/fonts/ibm-plex-sans/IBMPlexSans-Medium.ttf new file mode 100644 index 0000000..a3826c4 Binary files /dev/null and b/public/fonts/ibm-plex-sans/IBMPlexSans-Medium.ttf differ diff --git a/public/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf b/public/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf new file mode 100644 index 0000000..bd6817d Binary files /dev/null and b/public/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf differ diff --git a/public/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf b/public/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf new file mode 100644 index 0000000..09ec8cd Binary files /dev/null and b/public/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf differ diff --git a/public/fonts/ibm-plex-sans/OFL.txt b/public/fonts/ibm-plex-sans/OFL.txt new file mode 100755 index 0000000..c35c4c6 --- /dev/null +++ b/public/fonts/ibm-plex-sans/OFL.txt @@ -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. diff --git a/public/fonts/manifest.json b/public/fonts/manifest.json new file mode 100644 index 0000000..f682807 --- /dev/null +++ b/public/fonts/manifest.json @@ -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 + } + ] + } + ] +} diff --git a/public/fonts/noto-sans-devanagari/NotoSansDevanagari-Bold.ttf b/public/fonts/noto-sans-devanagari/NotoSansDevanagari-Bold.ttf new file mode 100644 index 0000000..49d3618 Binary files /dev/null and b/public/fonts/noto-sans-devanagari/NotoSansDevanagari-Bold.ttf differ diff --git a/public/fonts/noto-sans-devanagari/NotoSansDevanagari-Regular.ttf b/public/fonts/noto-sans-devanagari/NotoSansDevanagari-Regular.ttf new file mode 100644 index 0000000..8dd3ec4 Binary files /dev/null and b/public/fonts/noto-sans-devanagari/NotoSansDevanagari-Regular.ttf differ diff --git a/public/fonts/noto-sans-devanagari/OFL.txt b/public/fonts/noto-sans-devanagari/OFL.txt new file mode 100644 index 0000000..9651ea7 --- /dev/null +++ b/public/fonts/noto-sans-devanagari/OFL.txt @@ -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. diff --git a/public/fonts/noto-sans-kannada/NotoSansKannada-Bold.ttf b/public/fonts/noto-sans-kannada/NotoSansKannada-Bold.ttf new file mode 100644 index 0000000..2b5284a Binary files /dev/null and b/public/fonts/noto-sans-kannada/NotoSansKannada-Bold.ttf differ diff --git a/public/fonts/noto-sans-kannada/NotoSansKannada-Regular.ttf b/public/fonts/noto-sans-kannada/NotoSansKannada-Regular.ttf new file mode 100644 index 0000000..68da17f Binary files /dev/null and b/public/fonts/noto-sans-kannada/NotoSansKannada-Regular.ttf differ diff --git a/public/fonts/noto-sans-kannada/OFL.txt b/public/fonts/noto-sans-kannada/OFL.txt new file mode 100644 index 0000000..9651ea7 --- /dev/null +++ b/public/fonts/noto-sans-kannada/OFL.txt @@ -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. diff --git a/scripts/fonts/build-manifest.mjs b/scripts/fonts/build-manifest.mjs new file mode 100644 index 0000000..c874f38 --- /dev/null +++ b/scripts/fonts/build-manifest.mjs @@ -0,0 +1,29 @@ +// Regenerates public/fonts/manifest.json from the TTFs under public/fonts//. +// 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`, +); diff --git a/scripts/fonts/lib.mjs b/scripts/fonts/lib.mjs new file mode 100644 index 0000000..a1f6d79 --- /dev/null +++ b/scripts/fonts/lib.mjs @@ -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, +}; diff --git a/scripts/fonts/verify.mjs b/scripts/fonts/verify.mjs new file mode 100644 index 0000000..857e0b1 --- /dev/null +++ b/scripts/fonts/verify.mjs @@ -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`); diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx index 22b0b08..f2a31ab 100644 --- a/src/lib/pdf.tsx +++ b/src/lib/pdf.tsx @@ -2,6 +2,7 @@ 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 { bankFromSnapshot, vendorFromSnapshot } from "../pdf/model/vendor"; import { api } from "./api"; import type { BankAccount, Invoice, Settings } from "./types"; @@ -17,15 +18,6 @@ function blobToBase64(blob: Blob): Promise { }); } -function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings { - if (!snapshot) return live; - try { - return { ...live, ...(JSON.parse(snapshot) as Partial) }; - } catch { - return live; - } -} - export async function buildPdfProps( invoice: Invoice, settings: Settings, @@ -39,14 +31,7 @@ export async function buildPdfProps( ? await api.readAssetDataUri(invoice.signaturePath) : null; - let resolvedBank = bank; - if (!resolvedBank && invoice.bankSnapshot) { - try { - resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount; - } catch { - resolvedBank = null; - } - } + const resolvedBank = bankFromSnapshot(invoice.bankSnapshot, bank); return { vendor, diff --git a/src/pdf/engine/columns.test.ts b/src/pdf/engine/columns.test.ts new file mode 100644 index 0000000..c88d5bb --- /dev/null +++ b/src/pdf/engine/columns.test.ts @@ -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[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 }); + }); +}); diff --git a/src/pdf/engine/columns.ts b/src/pdf/engine/columns.ts new file mode 100644 index 0000000..f80339b --- /dev/null +++ b/src/pdf/engine/columns.ts @@ -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; diff --git a/src/pdf/engine/geometry.test.ts b/src/pdf/engine/geometry.test.ts new file mode 100644 index 0000000..3a5f344 --- /dev/null +++ b/src/pdf/engine/geometry.test.ts @@ -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); + }); +}); diff --git a/src/pdf/engine/geometry.ts b/src/pdf/engine/geometry.ts new file mode 100644 index 0000000..36dbaaf --- /dev/null +++ b/src/pdf/engine/geometry.ts @@ -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 = { + 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 = { + 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, + }; +} diff --git a/src/pdf/fonts.ts b/src/pdf/fonts.ts index d15cd67..f89de13 100644 --- a/src/pdf/fonts.ts +++ b/src/pdf/fonts.ts @@ -1,38 +1,11 @@ -import { Font } from "@react-pdf/renderer"; - -let registered = false; +import { registerBundledFonts } from "./fonts/register"; /** * Registers the fonts used by the invoice PDF. Safe to call more than once. * * `base` is a URL prefix inside the app ("/fonts") and a directory path when rendering under Node - * (tests). Use static TTF or WOFF files only: react-pdf embeds WOFF2 glyphs without outlines, which - * prints invisible text, and variable fonts embed only their default instance. + * (tests); the families live in per-family folders below it (see public/fonts/manifest.json). */ export function registerPdfFonts(base = "/fonts"): void { - if (registered) return; - registered = true; - - Font.register({ - family: "IBM Plex Sans", - fonts: [ - { src: `${base}/IBMPlexSans-Light.woff`, fontWeight: 300 }, - { src: `${base}/IBMPlexSans-Regular.woff`, fontWeight: 400 }, - { src: `${base}/IBMPlexSans-Italic.woff`, fontWeight: 400, fontStyle: "italic" }, - { src: `${base}/IBMPlexSans-Medium.woff`, fontWeight: 500 }, - { src: `${base}/IBMPlexSans-SemiBold.woff`, fontWeight: 600 }, - { src: `${base}/IBMPlexSans-Bold.woff`, fontWeight: 700 }, - ], - }); - - Font.register({ - family: "IBM Plex Mono", - fonts: [ - { src: `${base}/IBMPlexMono-Regular.woff`, fontWeight: 400 }, - { src: `${base}/IBMPlexMono-SemiBold.woff`, fontWeight: 600 }, - ], - }); - - // The default hyphenation inserts "-" into e-mail addresses, URLs and identifiers. - Font.registerHyphenationCallback((word) => [word]); + registerBundledFonts(base); } diff --git a/src/pdf/fonts/manifest.ts b/src/pdf/fonts/manifest.ts new file mode 100644 index 0000000..db0b28c --- /dev/null +++ b/src/pdf/fonts/manifest.ts @@ -0,0 +1,35 @@ +import manifestJson from "../../../public/fonts/manifest.json"; + +export interface FontFileEntry { + file: string; + weight: number; + style: "normal" | "italic"; + sha256: string; + fsType: number; + upm: number; + ascent: number; + descent: number; + lineGap: number; + capHeight: number; + xHeight: number; + hasRupee: boolean; + hasTabularNums: boolean; +} + +export interface FontFamilyEntry { + family: string; + folder: string; + license: string; + licenseFile: string; + fonts: FontFileEntry[]; +} + +export interface FontManifest { + version: 1; + families: FontFamilyEntry[]; +} + +// Generated by scripts/fonts/build-manifest.mjs. +export const FONT_MANIFEST = manifestJson as FontManifest; + +export const FONT_FAMILIES: readonly string[] = FONT_MANIFEST.families.map((f) => f.family); diff --git a/src/pdf/fonts/register.test.ts b/src/pdf/fonts/register.test.ts new file mode 100644 index 0000000..4ce92d8 --- /dev/null +++ b/src/pdf/fonts/register.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { FONT_FAMILIES, FONT_MANIFEST } from "./manifest"; +import { FONT_STACK, preflightFonts, stack } from "./register"; + +const base = path.resolve(process.cwd(), "public/fonts"); +const goodFont = () => { + const b = new Uint8Array(20_000); + b.set([0, 1, 0, 0]); + return b; +}; + +describe("font manifest", () => { + it("lists the four bundled families", () => { + expect(FONT_FAMILIES).toEqual([ + "IBM Plex Mono", + "IBM Plex Sans", + "Noto Sans Devanagari", + "Noto Sans Kannada", + ]); + for (const fam of FONT_MANIFEST.families) expect(fam.license).toBe("OFL-1.1"); + }); + + it("records the rupee sign in Plex Sans for every weight", () => { + const plex = FONT_MANIFEST.families.find((f) => f.family === "IBM Plex Sans")!; + expect(plex.fonts.every((f) => f.hasRupee)).toBe(true); + }); +}); + +describe("stack", () => { + it("puts the primary first and never duplicates", () => { + expect(stack("Jost")).toEqual(["Jost", ...FONT_STACK]); + expect(stack("IBM Plex Sans")).toEqual([...FONT_STACK]); + expect(stack("Noto Sans Kannada")).toEqual([ + "Noto Sans Kannada", + "IBM Plex Sans", + "Noto Sans Devanagari", + ]); + }); +}); + +describe("preflightFonts", () => { + it("passes for the real files on disk", async () => { + const problems = await preflightFonts( + async (url) => new Uint8Array(readFileSync(path.join(base, url.slice("/fonts/".length)))), + "/fonts", + ); + expect(problems).toEqual([]); + }); + + it("accepts a fake that serves valid fonts", async () => { + expect(await preflightFonts(async () => goodFont(), "/fonts")).toEqual([]); + }); + + it("flags index.html served with 200, tiny files, WOFF2 and failed fetches", async () => { + const html = new TextEncoder().encode(`${" ".repeat(20_000)}`); + const tiny = new Uint8Array([0, 1, 0, 0, 1, 2, 3]); + const woff2 = new Uint8Array(20_000); + woff2.set([0x77, 0x4f, 0x46, 0x32]); + let n = 0; + const problems = await preflightFonts(async () => { + n++; + if (n === 1) return html; + if (n === 2) return tiny; + if (n === 3) return woff2; + if (n === 4) throw new Error("HTTP 404"); + return goodFont(); + }, "/fonts"); + expect(problems).toHaveLength(4); + expect(problems[0]).toContain("not a TrueType font"); + expect(problems[1]).toContain("only 7 bytes"); + expect(problems[2]).toContain("not a TrueType font"); + expect(problems[3]).toContain("HTTP 404"); + }); +}); diff --git a/src/pdf/fonts/register.ts b/src/pdf/fonts/register.ts new file mode 100644 index 0000000..67cc9bb --- /dev/null +++ b/src/pdf/fonts/register.ts @@ -0,0 +1,85 @@ +import { Font } from "@react-pdf/renderer"; +import { FONT_MANIFEST } from "./manifest"; + +/** Every stack ends here: Latin and the rupee sign from Plex, then the Indic scripts. */ +export const FONT_STACK: readonly string[] = [ + "IBM Plex Sans", + "Noto Sans Devanagari", + "Noto Sans Kannada", +]; + +/** `[primary, ...tail]` without duplicates, for a style's fontFamily. */ +export function stack(primary: string): string[] { + return [primary, ...FONT_STACK.filter((f) => f !== primary)]; +} + +let registered = false; + +/** + * Registers every bundled family from the manifest. Idempotent; never call Font.reset(), the fonts + * would not load again. + * + * `base` is a URL prefix in the app ("/fonts") and a directory under Node (tests). Only static TTF is + * used: WOFF2 embeds glyphs without outlines (invisible text) and variable fonts embed only their + * default instance. + */ +export function registerBundledFonts(base: string): void { + if (registered) return; + registered = true; + + for (const fam of FONT_MANIFEST.families) { + const sources: { src: string; fontWeight: number; fontStyle: "normal" | "italic" }[] = []; + const weights = new Set(); + for (const font of fam.fonts) { + weights.add(font.weight); + sources.push({ + src: `${base}/${fam.folder}/${font.file}`, + fontWeight: font.weight, + fontStyle: font.style, + }); + } + // react-pdf resolves every family in a fallback stack with the node's own style and throws for + // a family that has no italic, so any weight without a real italic reuses its upright file. + for (const weight of weights) { + if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue; + const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal"); + if (upright) sources.push({ ...upright, fontStyle: "italic" }); + } + Font.register({ family: fam.family, fonts: sources }); + } + + // The default hyphenation inserts "-" into e-mail addresses, URLs and identifiers. + Font.registerHyphenationCallback((word) => [word]); +} + +const MIN_FONT_BYTES = 10 * 1024; + +function hasTrueTypeMagic(b: Uint8Array): boolean { + if (b.length < 4) return false; + if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return true; + return String.fromCharCode(b[0], b[1], b[2], b[3]) === "true"; +} + +/** + * Fetches every bundled font and returns a list of problems (empty when all are fine). Tauri answers + * an unknown path with index.html and HTTP 200, so the response bytes must be checked, not the status. + */ +export async function preflightFonts( + fetchBytes: (url: string) => Promise, + base: string, +): Promise { + const problems: string[] = []; + for (const fam of FONT_MANIFEST.families) { + for (const font of fam.fonts) { + const url = `${base}/${fam.folder}/${font.file}`; + try { + const bytes = await fetchBytes(url); + if (!hasTrueTypeMagic(bytes)) problems.push(`${url}: not a TrueType font`); + else if (bytes.length <= MIN_FONT_BYTES) problems.push(`${url}: only ${bytes.length} bytes`); + } catch (err) { + problems.push(`${url}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + return problems; +} diff --git a/src/pdf/fonts/render.test.tsx b/src/pdf/fonts/render.test.tsx new file mode 100644 index 0000000..d8a0689 --- /dev/null +++ b/src/pdf/fonts/render.test.tsx @@ -0,0 +1,31 @@ +import { createElement as h } from "react"; +import path from "node:path"; +import { renderToBuffer, Document, Page, Text } from "@react-pdf/renderer"; +import { describe, expect, it } from "vitest"; +import { registerBundledFonts, stack } from "./register"; + +registerBundledFonts(path.resolve(process.cwd(), "public/fonts")); + +const render = (page: object, style: object) => + renderToBuffer( + h(Document, null, h(Page, page as never, h(Text, { style: style as never }, "Total ₹1,23,456.78 नमस्ते ಕನ್ನಡ"))), + ); + +describe("bundled fonts render", () => { + it("renders every family-stack in italic and bold-italic without throwing", async () => { + for (const fontWeight of [300, 400, 500, 600, 700]) { + const buf = await render({ size: "A4" }, { fontFamily: stack("IBM Plex Sans"), fontStyle: "italic", fontWeight }); + expect(buf.subarray(0, 5).toString()).toBe("%PDF-"); + } + const mono = await render({ size: "A4" }, { fontFamily: stack("IBM Plex Mono"), fontStyle: "italic", fontWeight: 600 }); + expect(mono.length).toBeGreaterThan(1000); + }); + + it("accepts mm strings in the Page size object", async () => { + const buf = await render({ size: { width: "210mm", height: "297mm" } }, { fontFamily: stack("IBM Plex Sans") }); + const m = /\/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]/.exec(buf.toString("latin1")); + expect(m).not.toBeNull(); + expect(Number(m![1])).toBeCloseTo(595.28, 1); + expect(Number(m![2])).toBeCloseTo(841.89, 1); + }); +}); diff --git a/src/pdf/model/build.test.ts b/src/pdf/model/build.test.ts new file mode 100644 index 0000000..8b73469 --- /dev/null +++ b/src/pdf/model/build.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import type { BankAccount, Invoice, InvoiceItem, Settings } from "../../lib/types"; +import { buildRenderModel, type BuildInput } from "./build"; +import { bankFromSnapshot, vendorFromSnapshot } from "./vendor"; + +const vendor: Settings = { + vendorName: "Test Vendor", + vendorAddress: "13-B Type House,\r\nTest Road, Test Town\nBangalore - 560005", + vendorEmail: "Sample@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 items: InvoiceItem[] = [ + { description: "Voice-over\nsession A", mode: "rate", rate: 1200, unit: "hour", quantity: 2.5, amount: 3000, sortOrder: 0, hsnSac: "998397" }, + { description: "", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 2500.5, sortOrder: 1 }, +]; + +const base: Invoice = { + id: 1, + number: "INV-0001", + seriesId: 1, + invoiceDate: "2026-09-15", + dueDate: "2026-10-05", + clientId: null, + clientName: "Acme Pvt Ltd", + clientAddress: "1 Main Road\r\nMumbai", + clientGstin: "", + poNumber: "", + placeOfSupplyStateCode: "", + subtotal: 5500.5, + discount: 0, + taxType: "none", + taxRate: 0, + cgstAmount: 0, + sgstAmount: 0, + igstAmount: 0, + total: 5500.5, + amountInWords: "Five Thousand Five Hundred Rupees and Fifty Paise Only", + bankAccountId: 1, + bankSnapshot: "", + signaturePath: null, + notes: "Thanks\r\n\r\nPay in 30 days", + status: "issued", + createdAt: "2026-09-15T00:00:00Z", + updatedAt: "2026-09-15T00:00:00Z", + docType: "invoice", + reverseCharge: false, + vendorSnapshot: "", + snapshotOrigin: "", + cancelledAt: null, + cancelReason: "", + archivedPdfSha256: null, + items, +}; + +const make = (over: Partial = {}, v: Partial = {}, rest: Partial = {}) => + buildRenderModel({ + invoice: { ...base, ...over }, + vendor: { ...vendor, ...v }, + bank, + logo: null, + signatureDataUri: null, + appVersion: "0.1.0", + ...rest, + }); + +describe("unregistered invoice", () => { + const m = make(); + it("is a plain Invoice without tax slots", () => { + expect(m.docTitle).toBe("Invoice"); + expect(m.docType).toBe("invoice"); + expect(m.pdfTitle).toBe("Invoice INV-0001"); + expect(m.slots).toEqual({ gstin: false, clientGstin: false, pos: false, hsn: true, taxRows: false }); + expect(m.totals).toMatchObject({ + subtotalText: "₹5,500.50", + discountText: null, + taxableText: null, + taxRows: [], + totalText: "₹5,500.50", + }); + expect(m.supplier.gstin).toBeNull(); + expect(m.supplier.showGstin).toBe(false); + expect(m.client.posLabel).toBeNull(); + expect(m.cancelled).toBe(false); + expect(m.reverseCharge).toBeNull(); + expect(m.logo).toBeNull(); + }); + it("formats and sanitises text", () => { + expect(m.supplier.addressLines).toEqual(["13-B Type House,", "Test Road, Test Town", "Bangalore - 560005"]); + expect(m.supplier.stateName).toBe("Karnataka"); + expect(m.client.name).toBe("Acme Pvt Ltd"); + expect(m.client.addressLines).toEqual(["1 Main Road", "Mumbai"]); + expect(m.client.poNumber).toBeNull(); + expect(m.notesLines).toEqual(["Thanks", "Pay in 30 days"]); + expect(m.meta).toEqual({ numberLabel: "Invoice No.", number: "INV-0001", date: "15 Sept 2026", dueDate: "05 Oct 2026" }); + }); + it("builds items with rate, quantity and em-dash fallback", () => { + expect(m.items[0]).toEqual({ + sno: 1, + descriptionLines: ["Voice-over", "session A"], + hsnSac: "998397", + rateText: "₹1,200.00 per hour", + qtyText: "2.5", + amountText: "₹3,000.00", + }); + expect(m.items[1]).toEqual({ + sno: 2, + descriptionLines: ["—"], + hsnSac: null, + rateText: null, + qtyText: "1", + amountText: "₹2,500.50", + }); + }); + it("labels the bank lines and defaults the signature to the vendor name", () => { + expect(m.bank).toEqual({ + accountName: "Test Vendor", + lines: [ + { label: "Bank", value: "State Bank of India" }, + { label: "Account No.", value: "000000000000" }, + { label: "Branch", value: "000001" }, + { label: "IFSC", value: "TEST0000001" }, + ], + }); + expect(m.signature).toEqual({ dataUri: null, name: "Test Vendor", designation: "" }); + }); +}); + +describe("CGST + SGST tax invoice", () => { + const m = make( + { + docType: "tax_invoice", + taxType: "cgst_sgst", + taxRate: 5, + discount: 500.5, + cgstAmount: 125, + sgstAmount: 125, + total: 5250, + clientGstin: "29ABCDE1234F1Z5", + placeOfSupplyStateCode: "29", + poNumber: "PO-77", + reverseCharge: true, + }, + { vendorGstin: "29AAAAA0000A1Z5", gstRegistration: "regular", signatoryName: "A. Person", signatoryDesignation: "Proprietor" }, + ); + it("derives labels, rates and slots", () => { + expect(m.docTitle).toBe("Tax Invoice"); + expect(m.totals.taxRows).toEqual([ + { label: "CGST", rateText: "2.5%", amountText: "₹125.00" }, + { label: "SGST", rateText: "2.5%", amountText: "₹125.00" }, + ]); + expect(m.totals.discountText).toBe("₹500.50"); + expect(m.totals.taxableText).toBe("₹5,000.00"); + expect(m.totals.totalText).toBe("₹5,250.00"); + expect(m.slots).toEqual({ gstin: true, clientGstin: true, pos: true, hsn: true, taxRows: true }); + expect(m.client.posLabel).toBe("29 – Karnataka"); + expect(m.client.gstin).toBe("29ABCDE1234F1Z5"); + expect(m.client.poNumber).toBe("PO-77"); + expect(m.supplier.showGstin).toBe(true); + expect(m.reverseCharge).toMatch(/reverse charge/i); + expect(m.signature).toMatchObject({ name: "A. Person", designation: "Proprietor" }); + }); +}); + +describe("IGST", () => { + const m = make({ docType: "tax_invoice", taxType: "igst", taxRate: 18, igstAmount: 990.09, total: 6490.59, placeOfSupplyStateCode: "27" }); + it("has one IGST row at the full rate", () => { + expect(m.totals.taxRows).toEqual([{ label: "IGST", rateText: "18%", amountText: "₹990.09" }]); + expect(m.client.posLabel).toBe("27 – Maharashtra"); + }); +}); + +describe("UTGST supplier states", () => { + it.each(["04", "26", "31", "35", "38"])("uses UTGST for supplier state %s", (code) => { + const m = make({ docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 18, cgstAmount: 1, sgstAmount: 1 }, { vendorStateCode: code }); + expect(m.totals.taxRows.map((r) => r.label)).toEqual(["CGST", "UTGST"]); + }); + it("keeps SGST for ordinary states and Delhi/Puducherry", () => { + for (const code of ["29", "07", "34", "01"]) { + const m = make({ docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 18 }, { vendorStateCode: code }); + expect(m.totals.taxRows[1].label).toBe("SGST"); + } + }); + it("resolves the supplier state name", () => { + expect(make({}, { vendorStateCode: "04" }).supplier.stateName).toBe("Chandigarh"); + }); +}); + +describe("cancelled and logo", () => { + it("flags cancelled invoices", () => { + expect(make({ status: "cancelled" }).cancelled).toBe(true); + expect(make({ status: "draft" }).cancelled).toBe(false); + }); + it("passes the logo and signature through, and keeps null when absent", () => { + const logo = { dataUri: "data:image/png;base64,AA==", width: 200, height: 40 }; + const m = make({}, {}, { logo, signatureDataUri: "data:image/png;base64,BB==" }); + expect(m.logo).toEqual(logo); + expect(m.signature.dataUri).toBe("data:image/png;base64,BB=="); + expect(make().logo).toBeNull(); + }); +}); + +describe("snapshots", () => { + it("lets the invoice's vendor snapshot override live settings", () => { + const snap = JSON.stringify({ vendorName: "Old Name", vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5", gstRegistration: "regular" }); + const m = make({ vendorSnapshot: snap, docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 12 }); + expect(m.supplier.name).toBe("Old Name"); + expect(m.supplier.stateCode).toBe("04"); + expect(m.totals.taxRows[1].label).toBe("UTGST"); + expect(m.supplier.showGstin).toBe(true); + }); + it("falls back to live settings for a corrupt snapshot", () => { + expect(vendorFromSnapshot("{nope", vendor)).toBe(vendor); + expect(vendorFromSnapshot(undefined, vendor)).toBe(vendor); + expect(vendorFromSnapshot("", vendor)).toBe(vendor); + }); + it("parses the bank snapshot only when no bank is given", () => { + const snap = JSON.stringify({ ...bank, bankName: "HDFC" }); + expect(bankFromSnapshot(snap, null)?.bankName).toBe("HDFC"); + expect(bankFromSnapshot(snap, bank)).toBe(bank); + expect(bankFromSnapshot("{bad", null)).toBeNull(); + expect(bankFromSnapshot("", null)).toBeNull(); + const m = buildRenderModel({ invoice: { ...base, bankSnapshot: snap }, vendor, bank: null, logo: null, signatureDataUri: null, appVersion: "x" }); + expect(m.bank?.lines[0]).toEqual({ label: "Bank", value: "HDFC" }); + }); + it("omits the bank block when there is no bank", () => { + expect(make({}, {}, { bank: null }).bank).toBeNull(); + }); +}); diff --git a/src/pdf/model/build.ts b/src/pdf/model/build.ts new file mode 100644 index 0000000..555cee6 --- /dev/null +++ b/src/pdf/model/build.ts @@ -0,0 +1,236 @@ +import { computeLineAmount } from "../../lib/invoice"; +import { + INDIAN_STATES, + UNIT_LABELS, + type BankAccount, + type Invoice, + type Settings, +} from "../../lib/types"; +import { formatDate, formatMoney, formatRupees, toPaise } from "./format"; +import { sanitize, splitLines } from "./text"; +import { bankFromSnapshot, vendorFromSnapshot } from "./vendor"; + +export interface LogoInput { + dataUri: string; + width: number; + height: number; +} + +export interface BuildInput { + invoice: Invoice; + /** Live settings; the snapshot frozen on the invoice overrides them. */ + vendor: Settings; + bank: BankAccount | null; + logo: LogoInput | null; + signatureDataUri: string | null; + appVersion: string; +} + +export interface TaxRow { + label: string; + rateText?: string; + amountText: string; +} + +export interface RenderItem { + sno: number; + descriptionLines: string[]; + hsnSac: string | null; + rateText: string | null; + qtyText: string; + amountText: string; +} + +export interface RenderModel { + /** "Invoice" or "Tax Invoice"; the template decides the case. */ + docTitle: string; + docType: "invoice" | "tax_invoice"; + /** For the PDF metadata title. */ + pdfTitle: string; + supplier: { + name: string; + addressLines: string[]; + email: string; + phone: string; + pan: string; + gstin: string | null; + stateName: string; + stateCode: string; + showGstin: boolean; + }; + meta: { numberLabel: string; number: string; date: string; dueDate: string | null }; + client: { + name: string; + addressLines: string[]; + gstin: string | null; + /** "29 – Karnataka" */ + posLabel: string | null; + poNumber: string | null; + }; + items: RenderItem[]; + totals: { + subtotalText: string; + discountText: string | null; + /** Subtotal less discount; only for tax invoices. */ + taxableText: string | null; + taxRows: TaxRow[]; + totalText: string; + }; + amountInWords: string; + notesLines: string[]; + bank: { accountName: string; lines: { label: string; value: string }[] } | null; + signature: { dataUri: string | null; name: string; designation: string }; + logo: LogoInput | null; + footerText: string; + appVersion: string; + reverseCharge: string | null; + cancelled: boolean; + /** Which regulated slots have content, for the template contract tests. */ + slots: { + gstin: boolean; + clientGstin: boolean; + pos: boolean; + hsn: boolean; + taxRows: boolean; + }; +} + +/** Union territories without a legislature charge UTGST in place of SGST. */ +const UTGST_STATE_CODES = new Set(["04", "26", "31", "35", "38"]); + +const stateByCode = new Map(INDIAN_STATES.map((s) => [s.code, s.name])); + +const pct = (n: number) => `${Number((Number.isFinite(n) ? n : 0).toFixed(2))}%`; + +const orNull = (s: string | null | undefined) => { + const v = sanitize(s); + return v ? v : null; +}; + +function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] { + if (invoice.taxType === "cgst_sgst") { + const half = pct(invoice.taxRate / 2); + return [ + { label: "CGST", rateText: half, amountText: formatRupees(invoice.cgstAmount) }, + { + label: UTGST_STATE_CODES.has(supplierStateCode) ? "UTGST" : "SGST", + rateText: half, + amountText: formatRupees(invoice.sgstAmount), + }, + ]; + } + if (invoice.taxType === "igst") { + return [{ label: "IGST", rateText: pct(invoice.taxRate), amountText: formatRupees(invoice.igstAmount) }]; + } + return []; +} + +/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */ +export function buildRenderModel(input: BuildInput): RenderModel { + const { invoice, logo, signatureDataUri, appVersion } = input; + const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor); + const bank = bankFromSnapshot(invoice.bankSnapshot, input.bank); + + const docType = invoice.docType === "tax_invoice" ? "tax_invoice" : "invoice"; + const docTitle = docType === "tax_invoice" ? "Tax Invoice" : "Invoice"; + + const gstin = orNull(vendor.vendorGstin); + const showGstin = gstin !== null && (vendor.gstRegistration === "regular" || docType === "tax_invoice"); + const supplierStateCode = sanitize(vendor.vendorStateCode); + const vendorName = sanitize(vendor.vendorName) || "Vendor"; + + const posCode = sanitize(invoice.placeOfSupplyStateCode); + const posName = stateByCode.get(posCode); + const posLabel = posCode ? (posName ? `${posCode} – ${posName}` : posCode) : null; + + const items: RenderItem[] = invoice.items.map((item, i) => ({ + sno: i + 1, + descriptionLines: (() => { + const lines = splitLines(item.description); + return lines.length ? lines : ["—"]; + })(), + hsnSac: orNull(item.hsnSac), + rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null, + qtyText: item.mode === "rate" ? String(item.quantity) : "1", + amountText: formatRupees(computeLineAmount(item)), + })); + + const taxRows = taxRowsFor(invoice, supplierStateCode); + const discountPaise = toPaise(invoice.discount); + + const bankModel = bank + ? { + accountName: sanitize(bank.accountName) || vendorName, + lines: ( + [ + ["Bank", bank.bankName], + ["Account No.", bank.accountNo], + ["Branch", bank.branch], + ["IFSC", bank.ifsc], + ] as const + ) + .map(([label, value]) => ({ label, value: sanitize(value) })) + .filter((l) => l.value), + } + : null; + + const hsn = items.some((i) => i.hsnSac !== null); + + return { + docTitle, + docType, + pdfTitle: `${docTitle} ${invoice.number}`, + supplier: { + name: vendorName, + addressLines: splitLines(vendor.vendorAddress), + email: sanitize(vendor.vendorEmail), + phone: sanitize(vendor.vendorPhone), + pan: sanitize(vendor.vendorPan), + gstin, + stateName: stateByCode.get(supplierStateCode) ?? "", + stateCode: supplierStateCode, + showGstin, + }, + meta: { + numberLabel: "Invoice No.", + number: sanitize(invoice.number), + date: formatDate(invoice.invoiceDate), + dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : null, + }, + client: { + name: sanitize(invoice.clientName) || "—", + addressLines: splitLines(invoice.clientAddress), + gstin: orNull(invoice.clientGstin), + posLabel, + poNumber: orNull(invoice.poNumber), + }, + items, + totals: { + subtotalText: formatRupees(invoice.subtotal), + discountText: discountPaise > 0 ? formatMoney(discountPaise) : null, + taxableText: invoice.taxType === "none" ? null : formatMoney(toPaise(invoice.subtotal) - discountPaise), + taxRows, + totalText: formatRupees(invoice.total), + }, + amountInWords: sanitize(invoice.amountInWords), + notesLines: splitLines(invoice.notes), + bank: bankModel, + signature: { + dataUri: signatureDataUri, + name: sanitize(vendor.signatoryName) || vendorName, + designation: sanitize(vendor.signatoryDesignation), + }, + logo, + footerText: "Generated with Voiced — a Bongbetic product", + appVersion, + reverseCharge: invoice.reverseCharge ? "Tax is payable on reverse charge: Yes" : null, + cancelled: invoice.status === "cancelled", + slots: { + gstin: showGstin, + clientGstin: orNull(invoice.clientGstin) !== null, + pos: posLabel !== null, + hsn, + taxRows: taxRows.length > 0, + }, + }; +} diff --git a/src/pdf/model/format.test.ts b/src/pdf/model/format.test.ts new file mode 100644 index 0000000..0528c33 --- /dev/null +++ b/src/pdf/model/format.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { formatDate, formatMoney, formatRupees } from "./format"; + +describe("formatMoney", () => { + it("groups the Indian way", () => { + expect(formatMoney(0)).toBe("₹0.00"); + expect(formatMoney(5)).toBe("₹0.05"); + expect(formatMoney(99)).toBe("₹0.99"); + expect(formatMoney(100)).toBe("₹1.00"); + expect(formatMoney(99_900)).toBe("₹999.00"); + expect(formatMoney(100_000)).toBe("₹1,000.00"); + expect(formatMoney(2_500_000)).toBe("₹25,000.00"); + expect(formatMoney(12_345_678)).toBe("₹1,23,456.78"); + expect(formatMoney(1_000_000_000)).toBe("₹1,00,00,000.00"); + expect(formatMoney(123_456_789_012)).toBe("₹1,23,45,67,890.12"); + }); + it("handles negatives, the symbol flag and bad input", () => { + expect(formatMoney(-12_345_678)).toBe("-₹1,23,456.78"); + expect(formatMoney(-5)).toBe("-₹0.05"); + expect(formatMoney(12_345_678, false)).toBe("1,23,456.78"); + expect(formatMoney(-100_000, false)).toBe("-1,000.00"); + expect(formatMoney(Number.NaN)).toBe("₹0.00"); + expect(formatMoney(Number.POSITIVE_INFINITY)).toBe("₹0.00"); + expect(formatMoney(10.4)).toBe("₹0.10"); + }); + it("matches Intl en-IN for a sweep of values", () => { + const intl = new Intl.NumberFormat("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + for (let p = 0; p < 5_000_000_000; p = p * 7 + 13) { + expect(formatMoney(p, false)).toBe(intl.format(p / 100)); + } + }); +}); + +describe("formatRupees", () => { + it("converts real rupees through round(x * 100)", () => { + expect(formatRupees(25000)).toBe("₹25,000.00"); + expect(formatRupees(1234.565)).toBe("₹1,234.57"); + expect(formatRupees(0.1 + 0.2)).toBe("₹0.30"); + expect(formatRupees(-1.005, false)).toBe("-1.00"); + expect(formatRupees(Number.NaN)).toBe("₹0.00"); + }); +}); + +describe("formatDate", () => { + it("reproduces en-GB short output for every month", () => { + for (let m = 1; m <= 12; m++) { + const iso = `2026-${String(m).padStart(2, "0")}-05`; + const expected = new Date(`${iso}T00:00:00`).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); + // Node 22 prints "Sept" for September; other ICU builds may print "Sep", which is why the + // model hard-codes the table. + expect(formatDate(iso)).toBe(expected.replace("Sep ", "Sept ")); + } + }); + it("pins the two reference outputs", () => { + expect(formatDate("2026-09-15")).toBe("15 Sept 2026"); + expect(formatDate("2026-10-05")).toBe("05 Oct 2026"); + }); + it("passes through empty and invalid values", () => { + expect(formatDate("")).toBe(""); + expect(formatDate("not a date")).toBe("not a date"); + expect(formatDate("2026-02-30")).toBe("2026-02-30"); + expect(formatDate("2026-13-01")).toBe("2026-13-01"); + }); +}); diff --git a/src/pdf/model/format.ts b/src/pdf/model/format.ts new file mode 100644 index 0000000..519fa22 --- /dev/null +++ b/src/pdf/model/format.ts @@ -0,0 +1,46 @@ +// Deterministic formatters. No Intl or locale APIs: WebKitGTK and WebView2 may ship different ICU +// data, and an invoice must print the same everywhere. + +const RUPEE = "₹"; + +/** Indian grouping of a non-negative integer string: 1234567 -> "12,34,567". */ +function groupIndian(digits: string): string { + if (digits.length <= 3) return digits; + const head = digits.slice(0, -3); + const tail = digits.slice(-3); + return `${head.replace(/\B(?=(\d{2})+(?!\d))/g, ",")},${tail}`; +} + +/** Integer paise to "₹1,23,456.78" (or "1,23,456.78" without the symbol); negatives as "-₹…". */ +export function formatMoney(paise: number, withSymbol = true): string { + const value = Number.isFinite(paise) ? Math.round(paise) : 0; + const abs = Math.abs(value); + const rupees = Math.floor(abs / 100); + const frac = String(abs % 100).padStart(2, "0"); + const text = `${groupIndian(String(rupees))}.${frac}`; + const sign = value < 0 ? "-" : ""; + return `${sign}${withSymbol ? RUPEE : ""}${text}`; +} + +/** For the read model, which carries real rupees: converts through round(x * 100). */ +export function formatRupees(rupees: number, withSymbol = true): string { + const value = Number.isFinite(rupees) ? rupees : 0; + return formatMoney(Math.round(value * 100), withSymbol); +} + +/** Converts real rupees to integer paise. */ +export const toPaise = (rupees: number): number => (Number.isFinite(rupees) ? Math.round(rupees * 100) : 0); + +// Matches what en-GB prints today ("Sept" for September only), so existing PDFs do not change. +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"]; + +/** "2026-09-15" -> "15 Sept 2026", "2026-10-05" -> "05 Oct 2026". Unparseable input is returned as is. */ +export function formatDate(iso: string): string { + if (!iso) return ""; + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + if (!m) return iso; + const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])]; + const check = new Date(Date.UTC(year, month - 1, day)); + if (month < 1 || month > 12 || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return iso; + return `${String(day).padStart(2, "0")} ${MONTHS[month - 1]} ${m[1]}`; +} diff --git a/src/pdf/model/text.test.ts b/src/pdf/model/text.test.ts new file mode 100644 index 0000000..5dc5a4e --- /dev/null +++ b/src/pdf/model/text.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { chunkIdentifier, sanitize, splitLines } from "./text"; + +describe("sanitize", () => { + it("turns tabs and NBSP into spaces and collapses runs", () => { + expect(sanitize("a\tb")).toBe("a b"); + expect(sanitize("a  b")).toBe("a b"); + expect(sanitize("a b c")).toBe("a b c"); + expect(sanitize(" x ")).toBe("x"); + }); + it("strips control characters, ZWSP and BOM", () => { + expect(sanitize("a\u0000b\u0007c\u007Fd\u0085e")).toBe("abcde"); + expect(sanitize("hello​world")).toBe("helloworld"); + }); + it("keeps ZWNJ, ZWJ, the rupee sign and Indic text", () => { + expect(sanitize("a‌b‍c")).toBe("a‌b‍c"); + expect(sanitize("₹ 100")).toBe("₹ 100"); + expect(sanitize("नमस्ते ಕನ್ನಡ")).toBe("नमस्ते ಕನ್ನಡ"); + }); + it("handles nullish values and embedded newlines", () => { + expect(sanitize(null)).toBe(""); + expect(sanitize(undefined)).toBe(""); + expect(sanitize("a\r\nb")).toBe("a b"); + }); +}); + +describe("splitLines", () => { + it("splits on CRLF, LF and CR, trims and drops blanks", () => { + expect(splitLines("a\r\nb\n\n c \rd")).toEqual(["a", "b", "c", "d"]); + expect(splitLines("")).toEqual([]); + expect(splitLines(null)).toEqual([]); + }); + it("sanitises each line", () => { + expect(splitLines("a\t\tb\nc  d")).toEqual(["a b", "c d"]); + }); +}); + +describe("chunkIdentifier", () => { + it("breaks after separators and joins back to the input", () => { + const email = "very.long-name_here@example-company.co.in"; + for (const max of [4, 8, 12, 50]) { + const chunks = chunkIdentifier(email, max); + expect(chunks.join("")).toBe(email); + expect(chunks.every((c) => c.length <= max)).toBe(true); + } + expect(chunkIdentifier("a@b.com", 3)).toEqual(["a@", "b.", "com"]); + }); + it("keeps the whole value when it fits", () => { + expect(chunkIdentifier("a@b.com", 20)).toEqual(["a@b.com"]); + }); + it("hard-cuts a run longer than max", () => { + expect(chunkIdentifier("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]); + }); + it("handles empty input", () => { + expect(chunkIdentifier("", 5)).toEqual([]); + }); +}); diff --git a/src/pdf/model/text.ts b/src/pdf/model/text.ts new file mode 100644 index 0000000..7200fa4 --- /dev/null +++ b/src/pdf/model/text.ts @@ -0,0 +1,59 @@ +/** Normalises line endings and returns trimmed, non-empty, sanitised lines (never contains "\n"). */ +export function splitLines(value: string | null | undefined): string[] { + if (!value) return []; + return value + .replace(/\r\n?/g, "\n") + .split("\n") + .map((line) => sanitize(line)) + .filter(Boolean); +} + +/** + * Makes a string safe for a react-pdf Text: + * - control characters (including tab and newline) are removed, tab becoming a space; + * - NBSP becomes a space; zero-width space and BOM are dropped; ZWNJ and ZWJ are kept because + * Indic shaping needs them; + * - runs of spaces collapse to one, since two spaces before a line break make react-pdf insert a + * stray hyphen. + */ +export function sanitize(value: string | null | undefined): string { + if (!value) return ""; + return value + .replace(/\r\n?/g, "\n") + .replace(/[\t\n\v\f]/g, " ") + .replace(/ /g, " ") + .replace(/[\u0000-\u001F\u007F-\u009F​]/g, "") + .replace(/ {2,}/g, " ") + .trim(); +} + +/** + * Splits an identifier (e-mail, URL, path) into chunks that may wrap between, breaking after + * `@ . / - _` so the separator stays at the end of its chunk, and keeping each chunk at most `max` + * characters (a longer run is cut hard at `max`). Joining the chunks gives back the input. + */ +export function chunkIdentifier(value: string, max: number): string[] { + const limit = Math.max(1, Math.floor(max)); + const parts = value.match(/[^@./\-_]*[@./\-_]|[^@./\-_]+$/g) ?? []; + const chunks: string[] = []; + let current = ""; + const flush = () => { + if (current) chunks.push(current); + current = ""; + }; + for (const part of parts) { + if (current.length + part.length <= limit) { + current += part; + continue; + } + flush(); + let rest = part; + while (rest.length > limit) { + chunks.push(rest.slice(0, limit)); + rest = rest.slice(limit); + } + current = rest; + } + flush(); + return chunks; +} diff --git a/src/pdf/model/vendor.ts b/src/pdf/model/vendor.ts new file mode 100644 index 0000000..bf7a3ee --- /dev/null +++ b/src/pdf/model/vendor.ts @@ -0,0 +1,25 @@ +import type { BankAccount, Settings } from "../../lib/types"; + +/** + * Supplier details for an invoice: the snapshot frozen at issue wins over live settings, which are + * only a fallback for legacy rows without one (or with a corrupt one). + */ +export function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings { + if (!snapshot) return live; + try { + return { ...live, ...(JSON.parse(snapshot) as Partial) }; + } catch { + return live; + } +} + +/** The bank passed in wins; otherwise the account frozen on the invoice, if it parses. */ +export function bankFromSnapshot(snapshot: string | undefined, bank: BankAccount | null): BankAccount | null { + if (bank) return bank; + if (!snapshot) return null; + try { + return JSON.parse(snapshot) as BankAccount; + } catch { + return null; + } +}