Add bundled TTF fonts, page geometry, column allocator and render model
Static TTF fonts (IBM Plex Sans/Mono, Noto Devanagari/Kannada) with a fontkit-generated manifest, a verifier and OFL licences. Every family registers a normal and italic source so fallback stacks cannot throw. Adds A4/Letter geometry with margin presets, an exact column allocator, deterministic Indian-grouping money and date formatting, text sanitising and a pure buildRenderModel.
This commit is contained in:
Generated
+1
-1
@@ -12,7 +12,6 @@
|
|||||||
"@carbon/react": "^1.117.0",
|
"@carbon/react": "^1.117.0",
|
||||||
"@carbon/styles": "^1.116.0",
|
"@carbon/styles": "^1.116.0",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@napi-rs/canvas": "*",
|
|
||||||
"@react-pdf/renderer": "^4.9.0",
|
"@react-pdf/renderer": "^4.9.0",
|
||||||
"@tauri-apps/api": "^2.12.1",
|
"@tauri-apps/api": "^2.12.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||||
@@ -27,6 +26,7 @@
|
|||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"pdfjs-dist": "6.4.299",
|
"pdfjs-dist": "6.4.299",
|
||||||
"sass": "^1.105.1",
|
"sass": "^1.105.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
+4
-1
@@ -14,7 +14,9 @@
|
|||||||
"app:dev": "tauri dev",
|
"app:dev": "tauri dev",
|
||||||
"app:build": "tauri build --features custom-protocol",
|
"app:build": "tauri build --features custom-protocol",
|
||||||
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
|
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
|
||||||
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'"
|
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'",
|
||||||
|
"fonts:verify": "node scripts/fonts/verify.mjs",
|
||||||
|
"fonts:manifest": "node scripts/fonts/build-manifest.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"pdfjs-dist": "6.4.299",
|
"pdfjs-dist": "6.4.299",
|
||||||
"sass": "^1.105.1",
|
"sass": "^1.105.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
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`);
|
||||||
+2
-17
@@ -2,6 +2,7 @@ import { pdf } from "@react-pdf/renderer";
|
|||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import { registerPdfFonts } from "../pdf/fonts";
|
import { registerPdfFonts } from "../pdf/fonts";
|
||||||
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
|
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
|
||||||
|
import { bankFromSnapshot, vendorFromSnapshot } from "../pdf/model/vendor";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import type { BankAccount, Invoice, Settings } from "./types";
|
import type { BankAccount, Invoice, Settings } from "./types";
|
||||||
|
|
||||||
@@ -17,15 +18,6 @@ function blobToBase64(blob: Blob): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
export async function buildPdfProps(
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -39,14 +31,7 @@ export async function buildPdfProps(
|
|||||||
? await api.readAssetDataUri(invoice.signaturePath)
|
? await api.readAssetDataUri(invoice.signaturePath)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let resolvedBank = bank;
|
const resolvedBank = bankFromSnapshot(invoice.bankSnapshot, bank);
|
||||||
if (!resolvedBank && invoice.bankSnapshot) {
|
|
||||||
try {
|
|
||||||
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
|
|
||||||
} catch {
|
|
||||||
resolvedBank = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vendor,
|
vendor,
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-30
@@ -1,38 +1,11 @@
|
|||||||
import { Font } from "@react-pdf/renderer";
|
import { registerBundledFonts } from "./fonts/register";
|
||||||
|
|
||||||
let registered = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the fonts used by the invoice PDF. Safe to call more than once.
|
* 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
|
* `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
|
* (tests); the families live in per-family folders below it (see public/fonts/manifest.json).
|
||||||
* prints invisible text, and variable fonts embed only their default instance.
|
|
||||||
*/
|
*/
|
||||||
export function registerPdfFonts(base = "/fonts"): void {
|
export function registerPdfFonts(base = "/fonts"): void {
|
||||||
if (registered) return;
|
registerBundledFonts(base);
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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(`<!doctype html>${" ".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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number>();
|
||||||
|
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<Uint8Array>,
|
||||||
|
base: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: "Arun P",
|
||||||
|
vendorAddress: "13-B Type House,\r\nRobertson Road, Frazer Town\nBangalore - 560005",
|
||||||
|
vendorEmail: "arun@example.com",
|
||||||
|
vendorPhone: "9738284459",
|
||||||
|
vendorPan: "CPOPP7388K",
|
||||||
|
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: "Arun P",
|
||||||
|
accountNo: "54058708786",
|
||||||
|
branch: "017060",
|
||||||
|
ifsc: "SBIN0017060",
|
||||||
|
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<Invoice> = {}, v: Partial<Settings> = {}, rest: Partial<BuildInput> = {}) =>
|
||||||
|
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,", "Robertson Road, Frazer 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: "Arun P",
|
||||||
|
lines: [
|
||||||
|
{ label: "Bank", value: "State Bank of India" },
|
||||||
|
{ label: "Account No.", value: "54058708786" },
|
||||||
|
{ label: "Branch", value: "017060" },
|
||||||
|
{ label: "IFSC", value: "SBIN0017060" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(m.signature).toEqual({ dataUri: null, name: "Arun P", 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]}`;
|
||||||
|
}
|
||||||
@@ -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("helloworld")).toBe("helloworld");
|
||||||
|
});
|
||||||
|
it("keeps ZWNJ, ZWJ, the rupee sign and Indic text", () => {
|
||||||
|
expect(sanitize("abc")).toBe("abc");
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Settings>) };
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user