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:
2026-10-04 04:38:03 +05:30
parent fbed18a0bd
commit 92ba3d9590
40 changed files with 2162 additions and 49 deletions
+1 -1
View File
@@ -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
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+232
View File
@@ -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
}
]
}
]
}
+94
View File
@@ -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.
+94
View File
@@ -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.
+29
View File
@@ -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`,
);
+79
View File
@@ -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,
};
+76
View File
@@ -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
View File
@@ -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,
+101
View File
@@ -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 });
});
});
+90
View File
@@ -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;
+103
View File
@@ -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);
});
});
+94
View File
@@ -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
View File
@@ -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]);
} }
+35
View File
@@ -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);
+76
View File
@@ -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");
});
});
+85
View File
@@ -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;
}
+31
View File
@@ -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);
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, it } from "vitest";
import type { BankAccount, Invoice, InvoiceItem, Settings } from "../../lib/types";
import { buildRenderModel, type BuildInput } from "./build";
import { bankFromSnapshot, vendorFromSnapshot } from "./vendor";
const vendor: Settings = {
vendorName: "Test Vendor",
vendorAddress: "13-B Type House,\r\nTest Road, Test Town\nBangalore - 560005",
vendorEmail: "Sample@example.com",
vendorPhone: "9000000000",
vendorPan: "ABCDE1234F",
vendorGstin: "",
vendorStateCode: "29",
logoPath: null,
signaturePath: null,
defaultBankId: 1,
defaultTaxRate: 18,
defaultTaxType: "cgst_sgst",
paymentTermsDays: 30,
currency: "INR",
onboarded: true,
theme: "g10",
gstRegistration: "unregistered",
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
};
const bank: BankAccount = {
id: 1,
label: "Primary",
bankName: "State Bank of India",
accountName: "Test Vendor",
accountNo: "000000000000",
branch: "000001",
ifsc: "TEST0000001",
isDefault: true,
};
const items: InvoiceItem[] = [
{ description: "Voice-over\nsession A", mode: "rate", rate: 1200, unit: "hour", quantity: 2.5, amount: 3000, sortOrder: 0, hsnSac: "998397" },
{ description: "", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 2500.5, sortOrder: 1 },
];
const base: Invoice = {
id: 1,
number: "INV-0001",
seriesId: 1,
invoiceDate: "2026-09-15",
dueDate: "2026-10-05",
clientId: null,
clientName: "Acme Pvt Ltd",
clientAddress: "1 Main Road\r\nMumbai",
clientGstin: "",
poNumber: "",
placeOfSupplyStateCode: "",
subtotal: 5500.5,
discount: 0,
taxType: "none",
taxRate: 0,
cgstAmount: 0,
sgstAmount: 0,
igstAmount: 0,
total: 5500.5,
amountInWords: "Five Thousand Five Hundred Rupees and Fifty Paise Only",
bankAccountId: 1,
bankSnapshot: "",
signaturePath: null,
notes: "Thanks\r\n\r\nPay in 30 days",
status: "issued",
createdAt: "2026-09-15T00:00:00Z",
updatedAt: "2026-09-15T00:00:00Z",
docType: "invoice",
reverseCharge: false,
vendorSnapshot: "",
snapshotOrigin: "",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
items,
};
const make = (over: Partial<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,", "Test Road, Test Town", "Bangalore - 560005"]);
expect(m.supplier.stateName).toBe("Karnataka");
expect(m.client.name).toBe("Acme Pvt Ltd");
expect(m.client.addressLines).toEqual(["1 Main Road", "Mumbai"]);
expect(m.client.poNumber).toBeNull();
expect(m.notesLines).toEqual(["Thanks", "Pay in 30 days"]);
expect(m.meta).toEqual({ numberLabel: "Invoice No.", number: "INV-0001", date: "15 Sept 2026", dueDate: "05 Oct 2026" });
});
it("builds items with rate, quantity and em-dash fallback", () => {
expect(m.items[0]).toEqual({
sno: 1,
descriptionLines: ["Voice-over", "session A"],
hsnSac: "998397",
rateText: "₹1,200.00 per hour",
qtyText: "2.5",
amountText: "₹3,000.00",
});
expect(m.items[1]).toEqual({
sno: 2,
descriptionLines: ["—"],
hsnSac: null,
rateText: null,
qtyText: "1",
amountText: "₹2,500.50",
});
});
it("labels the bank lines and defaults the signature to the vendor name", () => {
expect(m.bank).toEqual({
accountName: "Test Vendor",
lines: [
{ label: "Bank", value: "State Bank of India" },
{ label: "Account No.", value: "000000000000" },
{ label: "Branch", value: "000001" },
{ label: "IFSC", value: "TEST0000001" },
],
});
expect(m.signature).toEqual({ dataUri: null, name: "Test Vendor", designation: "" });
});
});
describe("CGST + SGST tax invoice", () => {
const m = make(
{
docType: "tax_invoice",
taxType: "cgst_sgst",
taxRate: 5,
discount: 500.5,
cgstAmount: 125,
sgstAmount: 125,
total: 5250,
clientGstin: "29ABCDE1234F1Z5",
placeOfSupplyStateCode: "29",
poNumber: "PO-77",
reverseCharge: true,
},
{ vendorGstin: "29AAAAA0000A1Z5", gstRegistration: "regular", signatoryName: "A. Person", signatoryDesignation: "Proprietor" },
);
it("derives labels, rates and slots", () => {
expect(m.docTitle).toBe("Tax Invoice");
expect(m.totals.taxRows).toEqual([
{ label: "CGST", rateText: "2.5%", amountText: "₹125.00" },
{ label: "SGST", rateText: "2.5%", amountText: "₹125.00" },
]);
expect(m.totals.discountText).toBe("₹500.50");
expect(m.totals.taxableText).toBe("₹5,000.00");
expect(m.totals.totalText).toBe("₹5,250.00");
expect(m.slots).toEqual({ gstin: true, clientGstin: true, pos: true, hsn: true, taxRows: true });
expect(m.client.posLabel).toBe("29 – Karnataka");
expect(m.client.gstin).toBe("29ABCDE1234F1Z5");
expect(m.client.poNumber).toBe("PO-77");
expect(m.supplier.showGstin).toBe(true);
expect(m.reverseCharge).toMatch(/reverse charge/i);
expect(m.signature).toMatchObject({ name: "A. Person", designation: "Proprietor" });
});
});
describe("IGST", () => {
const m = make({ docType: "tax_invoice", taxType: "igst", taxRate: 18, igstAmount: 990.09, total: 6490.59, placeOfSupplyStateCode: "27" });
it("has one IGST row at the full rate", () => {
expect(m.totals.taxRows).toEqual([{ label: "IGST", rateText: "18%", amountText: "₹990.09" }]);
expect(m.client.posLabel).toBe("27 – Maharashtra");
});
});
describe("UTGST supplier states", () => {
it.each(["04", "26", "31", "35", "38"])("uses UTGST for supplier state %s", (code) => {
const m = make({ docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 18, cgstAmount: 1, sgstAmount: 1 }, { vendorStateCode: code });
expect(m.totals.taxRows.map((r) => r.label)).toEqual(["CGST", "UTGST"]);
});
it("keeps SGST for ordinary states and Delhi/Puducherry", () => {
for (const code of ["29", "07", "34", "01"]) {
const m = make({ docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 18 }, { vendorStateCode: code });
expect(m.totals.taxRows[1].label).toBe("SGST");
}
});
it("resolves the supplier state name", () => {
expect(make({}, { vendorStateCode: "04" }).supplier.stateName).toBe("Chandigarh");
});
});
describe("cancelled and logo", () => {
it("flags cancelled invoices", () => {
expect(make({ status: "cancelled" }).cancelled).toBe(true);
expect(make({ status: "draft" }).cancelled).toBe(false);
});
it("passes the logo and signature through, and keeps null when absent", () => {
const logo = { dataUri: "data:image/png;base64,AA==", width: 200, height: 40 };
const m = make({}, {}, { logo, signatureDataUri: "data:image/png;base64,BB==" });
expect(m.logo).toEqual(logo);
expect(m.signature.dataUri).toBe("data:image/png;base64,BB==");
expect(make().logo).toBeNull();
});
});
describe("snapshots", () => {
it("lets the invoice's vendor snapshot override live settings", () => {
const snap = JSON.stringify({ vendorName: "Old Name", vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5", gstRegistration: "regular" });
const m = make({ vendorSnapshot: snap, docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 12 });
expect(m.supplier.name).toBe("Old Name");
expect(m.supplier.stateCode).toBe("04");
expect(m.totals.taxRows[1].label).toBe("UTGST");
expect(m.supplier.showGstin).toBe(true);
});
it("falls back to live settings for a corrupt snapshot", () => {
expect(vendorFromSnapshot("{nope", vendor)).toBe(vendor);
expect(vendorFromSnapshot(undefined, vendor)).toBe(vendor);
expect(vendorFromSnapshot("", vendor)).toBe(vendor);
});
it("parses the bank snapshot only when no bank is given", () => {
const snap = JSON.stringify({ ...bank, bankName: "HDFC" });
expect(bankFromSnapshot(snap, null)?.bankName).toBe("HDFC");
expect(bankFromSnapshot(snap, bank)).toBe(bank);
expect(bankFromSnapshot("{bad", null)).toBeNull();
expect(bankFromSnapshot("", null)).toBeNull();
const m = buildRenderModel({ invoice: { ...base, bankSnapshot: snap }, vendor, bank: null, logo: null, signatureDataUri: null, appVersion: "x" });
expect(m.bank?.lines[0]).toEqual({ label: "Bank", value: "HDFC" });
});
it("omits the bank block when there is no bank", () => {
expect(make({}, {}, { bank: null }).bank).toBeNull();
});
});
+236
View File
@@ -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,
},
};
}
+68
View File
@@ -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");
});
});
+46
View File
@@ -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]}`;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { chunkIdentifier, sanitize, splitLines } from "./text";
describe("sanitize", () => {
it("turns tabs and NBSP into spaces and collapses runs", () => {
expect(sanitize("a\tb")).toBe("a b");
expect(sanitize("a  b")).toBe("a b");
expect(sanitize("a b c")).toBe("a b c");
expect(sanitize(" x ")).toBe("x");
});
it("strips control characters, ZWSP and BOM", () => {
expect(sanitize("a\u0000b\u0007c\u007Fd\u0085e")).toBe("abcde");
expect(sanitize("hello​world")).toBe("helloworld");
});
it("keeps ZWNJ, ZWJ, the rupee sign and Indic text", () => {
expect(sanitize("a‌b‍c")).toBe("a‌b‍c");
expect(sanitize("₹ 100")).toBe("₹ 100");
expect(sanitize("नमस्ते ಕನ್ನಡ")).toBe("नमस्ते ಕನ್ನಡ");
});
it("handles nullish values and embedded newlines", () => {
expect(sanitize(null)).toBe("");
expect(sanitize(undefined)).toBe("");
expect(sanitize("a\r\nb")).toBe("a b");
});
});
describe("splitLines", () => {
it("splits on CRLF, LF and CR, trims and drops blanks", () => {
expect(splitLines("a\r\nb\n\n c \rd")).toEqual(["a", "b", "c", "d"]);
expect(splitLines("")).toEqual([]);
expect(splitLines(null)).toEqual([]);
});
it("sanitises each line", () => {
expect(splitLines("a\t\tb\nc  d")).toEqual(["a b", "c d"]);
});
});
describe("chunkIdentifier", () => {
it("breaks after separators and joins back to the input", () => {
const email = "very.long-name_here@example-company.co.in";
for (const max of [4, 8, 12, 50]) {
const chunks = chunkIdentifier(email, max);
expect(chunks.join("")).toBe(email);
expect(chunks.every((c) => c.length <= max)).toBe(true);
}
expect(chunkIdentifier("a@b.com", 3)).toEqual(["a@", "b.", "com"]);
});
it("keeps the whole value when it fits", () => {
expect(chunkIdentifier("a@b.com", 20)).toEqual(["a@b.com"]);
});
it("hard-cuts a run longer than max", () => {
expect(chunkIdentifier("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]);
});
it("handles empty input", () => {
expect(chunkIdentifier("", 5)).toEqual([]);
});
});
+59
View File
@@ -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;
}
+25
View File
@@ -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;
}
}