Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e17ba3479 | ||
|
|
afbc0aa56f | ||
|
|
4d18a50753 |
@@ -30,3 +30,4 @@ xwin/
|
||||
# Local agent notes (kept on disk, not in the repo)
|
||||
CLAUDE.md
|
||||
handoff.md
|
||||
.commandcode/
|
||||
|
||||
@@ -108,12 +108,20 @@ Launch **Voiced** from the application menu, or run `voiced`. Remove it with
|
||||
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
|
||||
`libwebkit2gtk41`.
|
||||
|
||||
**Fonts on Void.** The package also installs the bundled TTF fonts under
|
||||
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
|
||||
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
|
||||
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
|
||||
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
|
||||
the destdir root (checked with xbps 0.60.7).
|
||||
|
||||
### Building through xbps-src
|
||||
|
||||
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
|
||||
|
||||
```bash
|
||||
cp -r packaging/void/template void-packages/srcpkgs/voiced/template
|
||||
mkdir -p void-packages/srcpkgs/voiced
|
||||
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
|
||||
cd void-packages && ./xbps-src pkg voiced
|
||||
```
|
||||
|
||||
|
||||
Vendored
+2
@@ -156,3 +156,5 @@ Built as `src/pdf/templates/monolith/`: a standalone template (`tokens.ts` the t
|
||||
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
|
||||
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
|
||||
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where the reference design's is at 812.
|
||||
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (band, info gaps, row padding) alone, as in Marble: the footer's own two gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 4 or 5 rows, Letter with 4).
|
||||
- **Flow notes guard.** The flow-notes block is preceded by a zero-height `TableGuard` asking for 84 pt (14 gap + 70 hold), so it never starts with less than its label and first line of room and collapses into zero-height lines at a page foot; it starts on the next page instead (Marble's guard).
|
||||
|
||||
Vendored
+1
@@ -155,4 +155,5 @@ Built as `src/pdf/templates/cobalt/`: a standalone template (`tokens.ts` the typ
|
||||
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
|
||||
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
|
||||
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
|
||||
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (header to client, client to table, last row to totals) alone, as in Marble: the terms and footer gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 7 rows).
|
||||
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
|
||||
#
|
||||
# Rebuilds the fontconfig cache for the bundled fonts after install and upgrade.
|
||||
# The stock x11-fonts trigger (enabled by font_dirs) only runs fc-cache when
|
||||
# mkfontdir and mkfontscale are installed, so it is not relied on here.
|
||||
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
|
||||
case "$1" in
|
||||
post)
|
||||
if command -v fc-cache >/dev/null 2>&1; then
|
||||
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
|
||||
#
|
||||
# Refreshes the fontconfig cache after the bundled fonts are removed, so stale
|
||||
# entries for /usr/share/fonts/voiced do not linger.
|
||||
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
|
||||
case "$1" in
|
||||
post)
|
||||
if command -v fc-cache >/dev/null 2>&1; then
|
||||
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -54,6 +54,27 @@ install_icon src-tauri/icons/icon.png 512x512
|
||||
|
||||
install -Dm644 README.md "$STAGE/usr/share/doc/voiced/README.md"
|
||||
|
||||
# Bundled fonts: one subdirectory per family under /usr/share/fonts/voiced, plus
|
||||
# each family's OFL text. Only *.ttf files are installed (not manifest.json, and
|
||||
# not the legacy woff/woff2 files that sit directly in public/fonts).
|
||||
for dir in public/fonts/*/; do
|
||||
family="$(basename "$dir")"
|
||||
for ttf in "$dir"*.ttf; do
|
||||
install -Dm644 "$ttf" "$STAGE/usr/share/fonts/voiced/$family/$(basename "$ttf")"
|
||||
done
|
||||
install -Dm644 "${dir}OFL.txt" "$STAGE/usr/share/licenses/voiced/${family}-OFL.txt"
|
||||
done
|
||||
|
||||
# xbps action scripts. xbps-create packs INSTALL and REMOVE found at the root of
|
||||
# the destdir into the archive (verified: they appear as ./INSTALL and ./REMOVE
|
||||
# members and are excluded from files.plist), and xbps runs them with $1 set to
|
||||
# the action. They refresh the fontconfig cache for /usr/share/fonts/voiced.
|
||||
# Fallback if a future xbps-create stops carrying them: font cache refresh would
|
||||
# then rely on fontconfig regenerating its cache on first use, or on the xbps-src
|
||||
# template (which uses the same scripts).
|
||||
install -Dm755 packaging/void/INSTALL "$STAGE/INSTALL"
|
||||
install -Dm755 packaging/void/REMOVE "$STAGE/REMOVE"
|
||||
|
||||
echo "==> Creating $PKGVER.$ARCH.xbps"
|
||||
# Kept outside Vite's `dist/` output directory, which is wiped on every build.
|
||||
OUT="$ROOT/build/void"
|
||||
@@ -71,7 +92,7 @@ mkdir -p "$REPO"
|
||||
-l "proprietary" \
|
||||
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
||||
-m "Xavier Karma <xavier@bongbetic.com>" \
|
||||
-D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \
|
||||
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
|
||||
-t "office finance" \
|
||||
--compression zstd \
|
||||
"$STAGE"
|
||||
|
||||
+23
-1
@@ -3,7 +3,9 @@
|
||||
# Canonical Void Linux package definition, to be built with xbps-src from a
|
||||
# void-packages checkout:
|
||||
#
|
||||
# cp -r packaging/void/template void-packages/srcpkgs/voiced/template
|
||||
# mkdir -p void-packages/srcpkgs/voiced
|
||||
# cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE \
|
||||
# void-packages/srcpkgs/voiced/
|
||||
# cd void-packages && ./xbps-src pkg voiced
|
||||
#
|
||||
# Notes:
|
||||
@@ -12,6 +14,13 @@
|
||||
# tarball, mirror it, or drop the sources into the xbps-src masterdir.
|
||||
# * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace
|
||||
# it with the real sha256 once the tarball is published.
|
||||
# * INSTALL and REMOVE (next to this file) are xbps action scripts that run
|
||||
# `fc-cache -f /usr/share/fonts/voiced` after install and removal. xbps-src
|
||||
# picks them up automatically from srcpkgs/voiced/INSTALL and
|
||||
# srcpkgs/voiced/REMOVE and packs them into the package; the template does
|
||||
# not install them. Copy them next to the template (see the commands above).
|
||||
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
|
||||
# unless mkfontdir and mkfontscale are installed, hence these scripts.
|
||||
# * If the build machine is offline, pre-populate the npm cache and Cargo
|
||||
# registry in the masterdir before building.
|
||||
#
|
||||
@@ -29,6 +38,9 @@ homepage="https://git.bongbetic.com/xavierk/Voiced"
|
||||
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
|
||||
checksum=SKIP
|
||||
compression="zstd"
|
||||
# Fonts install under a voiced/ subdirectory so they never conflict with the
|
||||
# files of Void's own font-ibm-plex-ttf and noto-fonts-ttf packages.
|
||||
font_dirs="/usr/share/fonts/voiced"
|
||||
|
||||
do_build() {
|
||||
npm install --include=dev --no-audit --no-fund
|
||||
@@ -51,4 +63,14 @@ do_install() {
|
||||
vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \
|
||||
usr/share/icons/hicolor/512x512/apps voiced.png
|
||||
vdoc "${wrksrc}/README.md"
|
||||
|
||||
# Bundled fonts: one subdirectory per family, plus its OFL text.
|
||||
# manifest.json and any other non-font file are deliberately skipped.
|
||||
for dir in "${wrksrc}"/public/fonts/*/; do
|
||||
family="$(basename "${dir}")"
|
||||
for ttf in "${dir}"*.ttf; do
|
||||
vinstall "${ttf}" 644 "usr/share/fonts/voiced/${family}"
|
||||
done
|
||||
vlicense "${dir}OFL.txt" "${family}-OFL.txt"
|
||||
done
|
||||
}
|
||||
|
||||
Generated
+1
@@ -4290,6 +4290,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"image",
|
||||
"percent-encoding",
|
||||
"rusqlite",
|
||||
|
||||
@@ -27,6 +27,7 @@ uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
flate2 = "1"
|
||||
percent-encoding = "2"
|
||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ pub mod archive;
|
||||
pub mod assets;
|
||||
pub mod clients;
|
||||
pub mod files;
|
||||
pub mod fonts;
|
||||
pub mod invoice;
|
||||
pub mod logo;
|
||||
pub mod raw;
|
||||
|
||||
+30
-3
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||
|
||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||
const LATEST_VERSION: i64 = 5;
|
||||
const LATEST_VERSION: i64 = 6;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
@@ -190,8 +190,35 @@ ALTER TABLE app_settings ADD COLUMN logo_meta TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE app_settings ADD COLUMN logo_includes_name TEXT NOT NULL DEFAULT 'auto';
|
||||
"#;
|
||||
|
||||
/// Version 6: user-imported fonts for the three licensed template faces. The files live content-addressed under
|
||||
/// `<local data dir>/fonts/<sha256>.<ttf|otf>`; invoices freeze the sha256 of the fonts they used in render_prefs.
|
||||
/// `hidden` retires a font that an issued invoice still references (never deleted while referenced).
|
||||
const M6: &str = r#"
|
||||
CREATE TABLE user_fonts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
face TEXT NOT NULL CHECK (face IN ('Now', 'Gotham', 'Open Sauce One')),
|
||||
family_name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
style_name TEXT NOT NULL DEFAULT '',
|
||||
weight INTEGER NOT NULL CHECK (weight BETWEEN 100 AND 900),
|
||||
style TEXT NOT NULL CHECK (style IN ('normal', 'italic')),
|
||||
sha256 TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
format TEXT NOT NULL CHECK (format IN ('ttf', 'otf')),
|
||||
size INTEGER NOT NULL,
|
||||
fs_type INTEGER NOT NULL DEFAULT 0,
|
||||
has_rupee INTEGER NOT NULL DEFAULT 0,
|
||||
licence_ack_at TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL,
|
||||
hidden INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256);
|
||||
-- One visible font per face, weight and style; a hidden one keeps its row but frees the slot.
|
||||
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
|
||||
"#;
|
||||
|
||||
fn migrations() -> Migrations<'static> {
|
||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5)])
|
||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)])
|
||||
}
|
||||
|
||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||
@@ -620,7 +647,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
assert_eq!(user_version(&old), 4);
|
||||
}
|
||||
let conn = open(&path, &dir.path().join("backups")).unwrap();
|
||||
assert_eq!(user_version(&conn), 5);
|
||||
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||
let (logo, meta): (String, String) = conn
|
||||
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
|
||||
@@ -141,6 +141,11 @@ pub fn run() {
|
||||
commands::archive::archive_pdf,
|
||||
commands::archive::read_archive,
|
||||
commands::archive::archive_status,
|
||||
commands::fonts::inspect_font,
|
||||
commands::fonts::import_font,
|
||||
commands::fonts::list_user_fonts,
|
||||
commands::fonts::get_user_font_bytes,
|
||||
commands::fonts::remove_user_font,
|
||||
commands::files::write_export_file,
|
||||
commands::files::get_last_export_dir,
|
||||
commands::files::reveal_in_folder,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FileUploaderButton,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
Tile,
|
||||
} from "@carbon/react";
|
||||
import { TrashCan } from "@carbon/icons-react";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
clientRefusal,
|
||||
FACE_SUBSTITUTES,
|
||||
notifyUserFontsChanged,
|
||||
USER_FACES,
|
||||
weightName,
|
||||
type FontInspection,
|
||||
type UserFace,
|
||||
type UserFontRow,
|
||||
} from "../lib/fontImport";
|
||||
import { TEMPLATE_CATALOG } from "../pdf/templates/catalog";
|
||||
|
||||
const MAX_FONT_BYTES = 16 * 1024 * 1024;
|
||||
const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
|
||||
interface Pending {
|
||||
face: UserFace;
|
||||
fileName: string;
|
||||
bytes: Uint8Array;
|
||||
inspection: FontInspection;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
const templatesUsing = (face: UserFace): string =>
|
||||
TEMPLATE_CATALOG.filter((t) => t.faces?.includes(face))
|
||||
.map((t) => t.name)
|
||||
.join(", ");
|
||||
|
||||
/**
|
||||
* Settings, Fonts: one row for each of the three licensed template faces (Now, Gotham, Open Sauce One), showing the
|
||||
* bundled look-alike in use and any imported files. Choosing a file shows what was found in it; the licence
|
||||
* acknowledgement is required before it is stored.
|
||||
*/
|
||||
export function FontOverrideTable() {
|
||||
const [fonts, setFonts] = useState<UserFontRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setFonts(await api.listUserFonts());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const choose = async (face: UserFace, file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setPending(null);
|
||||
if (file.size > MAX_FONT_BYTES) return setError("The font file is larger than the 16 MB limit.");
|
||||
setBusy(face);
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const refusal = clientRefusal(bytes);
|
||||
if (refusal) return setError(refusal);
|
||||
const inspection = await api.inspectFont(bytes);
|
||||
setPending({ face, fileName: file.name, bytes, inspection, weight: inspection.weight, italic: inspection.italic, acknowledged: false });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!pending || !pending.acknowledged) return;
|
||||
setBusy(pending.face);
|
||||
setError(null);
|
||||
try {
|
||||
await api.importFont(pending.bytes, {
|
||||
face: pending.face,
|
||||
fileName: pending.fileName,
|
||||
weight: pending.weight,
|
||||
italic: pending.italic,
|
||||
licenceAcknowledged: true,
|
||||
});
|
||||
setPending(null);
|
||||
await reload();
|
||||
notifyUserFontsChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (font: UserFontRow) => {
|
||||
const ok = await confirm(`Remove ${font.fileName} from ${font.face}? Templates go back to ${FACE_SUBSTITUTES[font.face]} for that weight.`, {
|
||||
title: "Remove font",
|
||||
kind: "warning",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(font.face);
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await api.removeUserFont(font.id);
|
||||
setNotice(
|
||||
outcome.hidden
|
||||
? `${font.fileName} is used by an issued invoice, so it was kept for that invoice and will not be used for new ones.`
|
||||
: `${font.fileName} was removed.`,
|
||||
);
|
||||
await reload();
|
||||
notifyUserFontsChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-stack">
|
||||
<p className="voiced-muted">
|
||||
Now, Gotham and Open Sauce One are commercial fonts, so Voiced draws them with free look-alikes. If you hold a licence
|
||||
for the real fonts, import the files here (TTF, OTF or WOFF; one file per weight). They are stored on this computer
|
||||
only. Invoices you issue keep the fonts they used.
|
||||
</p>
|
||||
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Font not imported" subtitle={error} /> : null}
|
||||
{notice ? <InlineNotification kind="info" lowContrast onCloseButtonClick={() => setNotice(null)} title={notice} /> : null}
|
||||
{loading ? (
|
||||
<InlineLoading description="Loading fonts…" />
|
||||
) : (
|
||||
<Table size="md" useZebraStyles={false} aria-label="Font overrides">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Face</TableHeader>
|
||||
<TableHeader>Drawn with</TableHeader>
|
||||
<TableHeader>Used by</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{USER_FACES.map((face) => {
|
||||
const files = fonts.filter((f) => f.face === face);
|
||||
return (
|
||||
<TableRow key={face}>
|
||||
<TableCell>{face}</TableCell>
|
||||
<TableCell>
|
||||
{files.length === 0 ? (
|
||||
<span className="voiced-muted">{FACE_SUBSTITUTES[face]} (bundled substitute)</span>
|
||||
) : (
|
||||
<div className="voiced-stack">
|
||||
{files.map((f) => (
|
||||
<div key={f.id} style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<span>
|
||||
{f.fullName || f.familyName} <span className="voiced-muted">({weightName(f.weight, f.style === "italic")}, {f.fileName})</span>
|
||||
</span>
|
||||
{!f.hasRupee ? <Tag size="sm" type="gray">no ₹</Tag> : null}
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
hasIconOnly
|
||||
renderIcon={TrashCan}
|
||||
iconDescription={`Remove ${f.fileName}`}
|
||||
tooltipPosition="left"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(f)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{templatesUsing(face)}</TableCell>
|
||||
<TableCell>
|
||||
<FileUploaderButton
|
||||
labelText={files.length === 0 ? "Import font…" : "Add or replace…"}
|
||||
buttonKind="tertiary"
|
||||
size="sm"
|
||||
accept={[".ttf", ".otf", ".woff"]}
|
||||
disabled={busy !== null}
|
||||
disableLabelChanges
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
void choose(face, file);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{busy && !pending ? <InlineLoading description="Reading the font…" /> : null}
|
||||
{pending ? <PendingImport pending={pending} onChange={setPending} onImport={() => void doImport()} busy={busy !== null} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingImport({
|
||||
pending,
|
||||
onChange,
|
||||
onImport,
|
||||
busy,
|
||||
}: {
|
||||
pending: Pending;
|
||||
onChange: (p: Pending | null) => void;
|
||||
onImport: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { inspection: i } = pending;
|
||||
return (
|
||||
<Tile>
|
||||
<div className="voiced-stack">
|
||||
<h4>
|
||||
{i.fullName || i.family} for {pending.face}
|
||||
</h4>
|
||||
<p className="voiced-muted">
|
||||
{pending.fileName} · {i.sourceFormat.toUpperCase()}
|
||||
{i.sourceFormat === "woff" ? " (unpacked)" : ""} · {(i.size / 1024).toFixed(0)} KB · {i.numGlyphs} glyphs · {i.hasBasicLatin ? "full basic Latin" : "incomplete basic Latin"} ·{" "}
|
||||
{i.hasRupee ? "has ₹" : "no ₹"}
|
||||
</p>
|
||||
{i.warnings.map((w) => (
|
||||
<InlineNotification key={w} kind="warning" lowContrast hideCloseButton title={w} />
|
||||
))}
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<Select
|
||||
id="font-import-weight"
|
||||
labelText="Weight"
|
||||
size="sm"
|
||||
value={String(pending.weight)}
|
||||
onChange={(e) => onChange({ ...pending, weight: Number(e.target.value) })}
|
||||
>
|
||||
{WEIGHTS.map((w) => (
|
||||
<SelectItem key={w} value={String(w)} text={weightName(w, false)} />
|
||||
))}
|
||||
</Select>
|
||||
<Checkbox id="font-import-italic" labelText="Italic" checked={pending.italic} onChange={(_, { checked }) => onChange({ ...pending, italic: checked })} />
|
||||
</div>
|
||||
<Checkbox
|
||||
id="font-import-licence"
|
||||
labelText={`I hold a licence that allows me to use this font in my invoices.`}
|
||||
checked={pending.acknowledged}
|
||||
onChange={(_, { checked }) => onChange({ ...pending, acknowledged: checked })}
|
||||
/>
|
||||
<div className="voiced-header-action">
|
||||
<Button size="sm" disabled={!pending.acknowledged || busy} onClick={onImport}>
|
||||
{busy ? "Importing…" : "Import font"}
|
||||
</Button>
|
||||
<Button kind="ghost" size="sm" onClick={() => onChange(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tile>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { onUserFontsChanged, type UserFontRow } from "../lib/fontImport";
|
||||
|
||||
/** The visible user-imported fonts; reloads when the Settings font table changes them. Empty while loading or on error. */
|
||||
export function useUserFonts(): UserFontRow[] {
|
||||
const [fonts, setFonts] = useState<UserFontRow[]>([]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = () =>
|
||||
api
|
||||
.listUserFonts()
|
||||
.then((rows) => active && setFonts(rows))
|
||||
.catch(() => active && setFonts([]));
|
||||
void load();
|
||||
const off = onUserFontsChanged(() => void load());
|
||||
return () => {
|
||||
active = false;
|
||||
off();
|
||||
};
|
||||
}, []);
|
||||
return fonts;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Settings,
|
||||
} from "./types";
|
||||
import type { LogoAsset } from "./logo";
|
||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||
|
||||
export interface ArchiveStatus {
|
||||
archived: boolean;
|
||||
@@ -70,6 +71,22 @@ export const api = {
|
||||
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
|
||||
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
|
||||
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
|
||||
// Imported fonts (Now, Gotham, Open Sauce One). The font bytes are the raw body; metadata goes in headers.
|
||||
inspectFont: (bytes: Uint8Array) => invoke<FontInspection>("inspect_font", bytes),
|
||||
importFont: (bytes: Uint8Array, meta: ImportMeta) =>
|
||||
invoke<UserFontRow>("import_font", bytes, {
|
||||
headers: {
|
||||
"x-face": encodeURIComponent(meta.face),
|
||||
"x-file-name": encodeURIComponent(meta.fileName),
|
||||
"x-licence-ack": String(meta.licenceAcknowledged),
|
||||
...(meta.weight !== undefined ? { "x-weight": String(meta.weight) } : {}),
|
||||
...(meta.italic !== undefined ? { "x-style": meta.italic ? "italic" : "normal" } : {}),
|
||||
},
|
||||
}),
|
||||
listUserFonts: (includeHidden = false) => invoke<UserFontRow[]>("list_user_fonts", { includeHidden }),
|
||||
getUserFontBytes: async (sha256: string): Promise<Uint8Array> =>
|
||||
new Uint8Array(await invoke<ArrayBuffer>("get_user_font_bytes", { sha256 })),
|
||||
removeUserFont: (id: number) => invoke<RemoveOutcome>("remove_user_font", { id }),
|
||||
writeExportFile: (path: string, bytes: Uint8Array) =>
|
||||
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
|
||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_PREFS } from "../pdf/model/prefs";
|
||||
import { clientRefusal, selectionFrom, withUserFonts, type UserFontRow } from "./fontImport";
|
||||
|
||||
const row = (id: number, face: UserFontRow["face"], sha: string, over: Partial<UserFontRow> = {}): UserFontRow => ({
|
||||
id, face, familyName: "F", fullName: "F", styleName: "", weight: 400, style: "normal", sha256: sha, fileName: "f.ttf",
|
||||
format: "ttf", size: 1, fsType: 0, hasRupee: true, licenceAckAt: "t", importedAt: "t", hidden: false, ...over,
|
||||
});
|
||||
const A = "a".repeat(64);
|
||||
const B = "b".repeat(64);
|
||||
const C = "c".repeat(64);
|
||||
|
||||
describe("clientRefusal", () => {
|
||||
const bytes = (magic: string) => new Uint8Array([...magic].map((c) => c.charCodeAt(0)).concat(new Array(20).fill(0)));
|
||||
it("passes TTF, OTF and WOFF and refuses the rest with a reason", () => {
|
||||
expect(clientRefusal(new Uint8Array([0, 1, 0, 0, 0, 0, 0, 0]))).toBeNull();
|
||||
expect(clientRefusal(bytes("OTTO"))).toBeNull();
|
||||
expect(clientRefusal(bytes("wOFF"))).toBeNull();
|
||||
expect(clientRefusal(bytes("wOF2"))).toMatch(/WOFF2/);
|
||||
expect(clientRefusal(bytes("ttcf"))).toMatch(/collections/);
|
||||
expect(clientRefusal(bytes("<htm"))).toMatch(/not a TrueType/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withUserFonts", () => {
|
||||
const fonts = [row(1, "Gotham", B), row(2, "Gotham", A, { weight: 700 }), row(3, "Now", C), row(4, "Now", "d".repeat(64), { hidden: true })];
|
||||
|
||||
it("lists visible fonts per face", () => {
|
||||
expect(selectionFrom(fonts)).toEqual({ Gotham: [B, A], Now: [C] });
|
||||
});
|
||||
|
||||
it("adds only the faces the template draws, sorted, and nothing for a template without them", () => {
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "purple-pop" }, fonts).fonts).toEqual({ Gotham: [A, B] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "citrus-split" }, fonts).fonts).toEqual({ Now: [C], Gotham: [A, B] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, fonts).fonts).toEqual({ Now: [C] });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "classic" }, fonts)).toEqual({ ...DEFAULT_PREFS, templateId: "classic" });
|
||||
expect(withUserFonts({ ...DEFAULT_PREFS, templateId: "marble" }, [])).toEqual({ ...DEFAULT_PREFS, templateId: "marble" });
|
||||
});
|
||||
|
||||
it("replaces fonts already in the prefs rather than merging", () => {
|
||||
const stale = { ...DEFAULT_PREFS, templateId: "marble", fonts: { Now: ["e".repeat(64)] } };
|
||||
expect(withUserFonts(stale, fonts).fonts).toEqual({ Now: [C] });
|
||||
expect(withUserFonts(stale, []).fonts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FontFormat, UserFace } from "../pdf/fonts/userFontStore";
|
||||
import { sniffFontFormat, USER_FACES } from "../pdf/fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
import { catalogEntry } from "../pdf/templates/catalog";
|
||||
|
||||
export { USER_FACES, type UserFace };
|
||||
|
||||
/** The three licensed faces and the bundled look-alike each one is drawn with until a font is imported. */
|
||||
export const FACE_SUBSTITUTES: Record<UserFace, string> = {
|
||||
Now: "Jost",
|
||||
Gotham: "Montserrat",
|
||||
"Open Sauce One": "Poppins",
|
||||
};
|
||||
|
||||
/** What the backend found in a font file (src-tauri/src/commands/fonts.rs `FontInspection`). */
|
||||
export interface FontInspection {
|
||||
format: "ttf" | "otf";
|
||||
sourceFormat: "ttf" | "otf" | "woff";
|
||||
family: string;
|
||||
subfamily: string;
|
||||
fullName: string;
|
||||
postscriptName: string;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
fsType: number;
|
||||
numGlyphs: number;
|
||||
hasRupee: boolean;
|
||||
hasBasicLatin: boolean;
|
||||
size: number;
|
||||
sha256: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** One stored font file (`UserFont`). */
|
||||
export interface UserFontRow {
|
||||
id: number;
|
||||
face: UserFace;
|
||||
familyName: string;
|
||||
fullName: string;
|
||||
styleName: string;
|
||||
weight: number;
|
||||
style: "normal" | "italic";
|
||||
sha256: string;
|
||||
fileName: string;
|
||||
format: "ttf" | "otf";
|
||||
size: number;
|
||||
fsType: number;
|
||||
hasRupee: boolean;
|
||||
licenceAckAt: string;
|
||||
importedAt: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveOutcome {
|
||||
deleted: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface ImportMeta {
|
||||
face: UserFace;
|
||||
fileName: string;
|
||||
weight?: number;
|
||||
italic?: boolean;
|
||||
licenceAcknowledged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same magic-byte check the backend makes, to refuse early with a plain message. TTF, OTF and WOFF pass (the
|
||||
* backend unpacks WOFF); the backend stays the authority and repeats every check.
|
||||
*/
|
||||
export function clientRefusal(bytes: Uint8Array): string | null {
|
||||
const format: FontFormat = sniffFontFormat(bytes);
|
||||
switch (format) {
|
||||
case "ttf":
|
||||
case "otf":
|
||||
case "woff":
|
||||
return null;
|
||||
case "woff2":
|
||||
return "WOFF2 cannot be used: it embeds in a PDF without glyph outlines (invisible text). Import the TTF, OTF or WOFF file instead.";
|
||||
case "ttc":
|
||||
return "Font collections (.ttc) are not supported. Import a single TTF or OTF file.";
|
||||
default:
|
||||
return "This is not a TrueType, OpenType or WOFF font file.";
|
||||
}
|
||||
}
|
||||
|
||||
export const weightName = (weight: number, italic: boolean): string => {
|
||||
const names: Record<number, string> = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
|
||||
return `${names[weight] ?? weight}${italic ? " Italic" : ""}`;
|
||||
};
|
||||
|
||||
/** Visible fonts grouped by face, as the sha256 list `prefs.fonts` carries. */
|
||||
export function selectionFrom(fonts: readonly UserFontRow[]): NonNullable<RenderPrefsV1["fonts"]> {
|
||||
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const f of fonts) {
|
||||
if (f.hidden) continue;
|
||||
(out[f.face] ??= []).push(f.sha256);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prefs to render and issue with: `prefs` plus the imported fonts for the faces this template draws. Fonts for
|
||||
* faces it does not use stay out, so an invoice only pins (and so only protects) the fonts it really uses. The
|
||||
* result is for rendering; keep the clean `prefs` for the page-setup controls and the saved default.
|
||||
*/
|
||||
export function withUserFonts(prefs: RenderPrefsV1, fonts: readonly UserFontRow[]): RenderPrefsV1 {
|
||||
const { fonts: _drop, ...rest } = prefs;
|
||||
const faces = catalogEntry(prefs.templateId).faces ?? [];
|
||||
const selection = selectionFrom(fonts);
|
||||
const used: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const face of faces) {
|
||||
const ids = selection[face];
|
||||
if (ids && ids.length > 0) used[face] = [...ids].sort();
|
||||
}
|
||||
return Object.keys(used).length > 0 ? { ...rest, fonts: used } : rest;
|
||||
}
|
||||
|
||||
// Settings and the invoice view are separate parts of the UI; this lets the list in one refresh the other.
|
||||
const listeners = new Set<() => void>();
|
||||
export const onUserFontsChanged = (cb: () => void): (() => void) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
};
|
||||
export const notifyUserFontsChanged = (): void => listeners.forEach((cb) => cb());
|
||||
+12
-1
@@ -1,11 +1,22 @@
|
||||
import { createAutoClient, type RenderClient } from "../pdf/render/client";
|
||||
import type { UserFace } from "../pdf/fonts/userFontStore";
|
||||
import { api } from "./api";
|
||||
|
||||
let client: RenderClient | null = null;
|
||||
let lastId = 0;
|
||||
|
||||
/** The one render client (and so one worker) shared by the preview and export. */
|
||||
export function getRenderClient(): RenderClient {
|
||||
client ??= createAutoClient();
|
||||
client ??= createAutoClient({
|
||||
// Font metadata comes from the database row, the bytes over the binary IPC; both by the file's sha256.
|
||||
loadUserFont: async (sha256) => {
|
||||
const rows = await api.listUserFonts(true);
|
||||
const row = rows.find((r) => r.sha256 === sha256);
|
||||
if (!row) throw new Error("An imported font used by this invoice is no longer in the font library");
|
||||
const bytes = await api.getUserFontBytes(sha256);
|
||||
return { face: row.face as UserFace, weight: row.weight, italic: row.style === "italic", sha256, bytes };
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create, type Font } from "fontkit";
|
||||
import { FONT_MANIFEST } from "../fonts/manifest";
|
||||
import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
import { userFamilySources } from "../fonts/userFontStore";
|
||||
|
||||
export interface MeasureInput {
|
||||
text: string;
|
||||
@@ -29,6 +30,29 @@ export interface Measurer {
|
||||
missing(text: string, face?: string): number[];
|
||||
}
|
||||
|
||||
const userFonts = new Map<string, Font>();
|
||||
|
||||
/**
|
||||
* The fontkit font of a user-imported family at a weight and style (the file react-pdf resolves for it), or
|
||||
* undefined when `familyName` is not a user family. Parsed once per file and shared by every measurer.
|
||||
*/
|
||||
export function userFontkitFont(familyName: string, weight: number, italic: boolean): Font | null | undefined {
|
||||
const sources = userFamilySources(familyName);
|
||||
if (sources.length === 0) return undefined;
|
||||
const src = resolveSource(
|
||||
sources.map((s) => ({ file: s.id, fontWeight: s.fontWeight, fontStyle: s.fontStyle })),
|
||||
weight,
|
||||
italic ? "italic" : "normal",
|
||||
);
|
||||
if (!src) return null;
|
||||
let font = userFonts.get(src.file);
|
||||
if (!font) {
|
||||
font = create((sources.find((s) => s.id === src.file) as { bytes: Uint8Array }).bytes) as Font;
|
||||
userFonts.set(src.file, font);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
/** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */
|
||||
function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined {
|
||||
const styled = sources.filter((s) => s.fontStyle === style);
|
||||
@@ -57,6 +81,8 @@ export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measure
|
||||
const fonts = new Map<string, Font>();
|
||||
|
||||
function load(familyName: string, weight: number, italic: boolean): Font | null {
|
||||
const own = userFontkitFont(familyName, weight, italic);
|
||||
if (own !== undefined) return own;
|
||||
const fam = FONT_MANIFEST.families.find((f) => f.family === familyName);
|
||||
if (!fam) return null;
|
||||
const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest";
|
||||
import { userFontkitFont } from "./measure";
|
||||
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
|
||||
function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined {
|
||||
@@ -23,6 +24,13 @@ function metricsFile(family: string, weight: number, italic: boolean): FontFileE
|
||||
export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly<Record<string, string>> } = {}): number {
|
||||
const token = roleToken(role);
|
||||
const family = resolveFace(token.face, opts.faceOverrides);
|
||||
// A user-imported font has no manifest entry; its metrics come from the font itself (hhea, as the manifest's do).
|
||||
const own = userFontkitFont(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
|
||||
if (own) {
|
||||
// fontkit's types omit the hhea metrics it exposes.
|
||||
const { ascent, descent, lineGap } = own as unknown as { ascent: number; descent: number; lineGap: number };
|
||||
return Math.ceil(((ascent - descent + lineGap) / own.unitsPerEm) * token.size) + 1;
|
||||
}
|
||||
const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false);
|
||||
if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`);
|
||||
// The manifest stores descent as a negative number, as the font's hhea table does.
|
||||
|
||||
+97
-12
@@ -1,6 +1,15 @@
|
||||
import { Font } from "@react-pdf/renderer";
|
||||
import { FONT_MANIFEST } from "./manifest";
|
||||
import { familySources } from "./stack";
|
||||
import {
|
||||
assertSfnt,
|
||||
familyNameFor,
|
||||
isUserFamilyKnown,
|
||||
rememberUserFiles,
|
||||
userFamilySources,
|
||||
type UserFace,
|
||||
type UserFontFile,
|
||||
} from "./userFontStore";
|
||||
|
||||
export { FONT_STACK, familySources, stack, type FamilySource } from "./stack";
|
||||
|
||||
@@ -71,7 +80,9 @@ function primeComponents(font: FkFont): void {
|
||||
for (const cp of lowest.values()) font.glyphForCodePoint(cp);
|
||||
}
|
||||
|
||||
let primed: Promise<void> | null = null;
|
||||
/** Descriptors already primed; a font registered after the first render (a user font) is primed on the next call. */
|
||||
const primedKeys = new Set<string>();
|
||||
let primeChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Loads every registered face and gives the component glyphs of its composites their code points. fontkit
|
||||
@@ -79,28 +90,102 @@ let primed: Promise<void> | null = null;
|
||||
* time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.'
|
||||
* would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00",
|
||||
* textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders
|
||||
* before it. Memoised; await it before the first render.
|
||||
* before it. Covers the bundled families and any user family registered so far, each descriptor once, so a
|
||||
* font registered later is not skipped. Await it before the first render.
|
||||
*/
|
||||
export function primeFontGlyphs(): Promise<void> {
|
||||
if (!registered) return Promise.resolve();
|
||||
if (!primed) {
|
||||
primed = run().catch((err) => {
|
||||
primed = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return primed;
|
||||
if (!registered && primedKeys.size === 0 && userFamilies.length === 0) return Promise.resolve();
|
||||
const next = primeChain.then(run);
|
||||
primeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const descriptors: Array<{ fontFamily: string; fontWeight: number; fontStyle: "normal" | "italic" }> = [];
|
||||
if (registered) {
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const s of familySources(fam)) {
|
||||
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle };
|
||||
for (const s of familySources(fam)) descriptors.push({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
|
||||
}
|
||||
}
|
||||
for (const family of userFamilies) {
|
||||
for (const s of userFamilySources(family)) descriptors.push({ fontFamily: family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
|
||||
}
|
||||
for (const descriptor of descriptors) {
|
||||
const key = `${descriptor.fontFamily}|${descriptor.fontWeight}|${descriptor.fontStyle}`;
|
||||
if (primedKeys.has(key)) continue;
|
||||
await Font.load(descriptor);
|
||||
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
|
||||
if (data) primeComponents(data);
|
||||
primedKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- User-imported fonts (Now, Gotham, Open Sauce One) ----
|
||||
|
||||
const userFamilies: string[] = [];
|
||||
const isNode = typeof process !== "undefined" && !!process.versions?.node;
|
||||
|
||||
/**
|
||||
* A src react-pdf can load the bytes from. In a browser or worker that is a blob: URL (react-pdf fetches it;
|
||||
* a data URI would be decoded character by character, which is slow for a multi-MB font). Under Node it is a
|
||||
* temporary file, because react-pdf's Node build opens anything that is not an http(s) URL as a path.
|
||||
*/
|
||||
async function sourceFor(bytes: Uint8Array, id: string): Promise<string> {
|
||||
if (!isNode) return URL.createObjectURL(new Blob([bytes as BlobPart], { type: "font/ttf" }));
|
||||
const nodeFs = "node:fs";
|
||||
const nodeOs = "node:os";
|
||||
const nodePath = "node:path";
|
||||
const [fs, os, path] = await Promise.all([import(/* @vite-ignore */ nodeFs), import(/* @vite-ignore */ nodeOs), import(/* @vite-ignore */ nodePath)]);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "voiced-userfont-"));
|
||||
const file = path.join(dir, `${id.slice(0, 16)}.ttf`);
|
||||
fs.writeFileSync(file, bytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers one face's files as the family `user:<Face>:<set id>` and returns that name. Every file's magic bytes
|
||||
* are checked before fontkit sees it. Each weight without an italic gets its upright file as the italic. Idempotent:
|
||||
* the same set registers once. Never resets the font store; a different set is a different family.
|
||||
*/
|
||||
export async function registerUserFontSet(face: UserFace, files: UserFontFile[]): Promise<string> {
|
||||
if (files.length === 0) throw new Error(`No font files were given for ${face}`);
|
||||
for (const f of files) assertSfnt(f.bytes);
|
||||
const family = familyNameFor(face, files.map((f) => f.sha256));
|
||||
if (isUserFamilyKnown(family)) return family;
|
||||
rememberUserFiles(family, files);
|
||||
const srcs = new Map<string, string>();
|
||||
for (const s of userFamilySources(family)) {
|
||||
if (!srcs.has(s.id)) srcs.set(s.id, await sourceFor(s.bytes, s.id));
|
||||
}
|
||||
Font.register({
|
||||
family,
|
||||
fonts: userFamilySources(family).map((s) => ({ src: srcs.get(s.id) as string, fontWeight: s.fontWeight as never, fontStyle: s.fontStyle })),
|
||||
});
|
||||
userFamilies.push(family);
|
||||
await primeFontGlyphs();
|
||||
return family;
|
||||
}
|
||||
|
||||
/** Cheap non-cryptographic id (two FNV-1a passes, 16 hex digits) for callers that have no sha256, i.e. tests. */
|
||||
function fallbackId(bytes: Uint8Array): string {
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x01000193;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
a = Math.imul(a ^ bytes[i], 0x01000193) >>> 0;
|
||||
b = Math.imul(b ^ bytes[i], 0x811c9dc5) >>> 0;
|
||||
}
|
||||
return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/** One-file convenience for Node tests and single-weight imports: the font as the only member of the face's set. */
|
||||
export async function registerUserFont(
|
||||
face: UserFace,
|
||||
bytes: Uint8Array,
|
||||
opts: { weight?: number; italic?: boolean; sha256?: string } = {},
|
||||
): Promise<{ family: string; sha256: string }> {
|
||||
const sha256 = opts.sha256 ?? fallbackId(bytes);
|
||||
const family = await registerUserFontSet(face, [{ face, weight: opts.weight ?? 400, italic: opts.italic ?? false, sha256, bytes }]);
|
||||
return { family, sha256 };
|
||||
}
|
||||
|
||||
const MIN_FONT_BYTES = 10 * 1024;
|
||||
|
||||
+55
-3
@@ -35,6 +35,13 @@ export interface TypeToken {
|
||||
tnum?: boolean;
|
||||
/** Smallest size IdText/FitText may shrink to. */
|
||||
minSize?: number;
|
||||
/**
|
||||
* Size and tracking to use instead of `size`/`tracking` when a user-imported font replaces this token's
|
||||
* substitute face. The token's own values are tuned for the substitute (Gotham drawn in Montserrat, Now in
|
||||
* Jost); the real font takes the design values. defineRoles turns a token that has this into one whose
|
||||
* size/tracking follow the active overrides; without an override its values are exactly the literals.
|
||||
*/
|
||||
ownFont?: { size?: number; tracking?: number };
|
||||
}
|
||||
|
||||
const SANS: FaceKey = "IBM Plex Sans";
|
||||
@@ -63,14 +70,37 @@ export type RoleTable<K extends string = string> = Record<K, TypeToken>;
|
||||
|
||||
/** Checks a role table against TypeToken and keeps its literal keys: `export const ROLES = defineRoles({ ... })`. */
|
||||
export function defineRoles<const T extends Record<string, TypeToken>>(table: T): T {
|
||||
return table;
|
||||
const out: Record<string, TypeToken> = {};
|
||||
for (const [name, token] of Object.entries(table)) out[name] = token.ownFont ? followOverrides(token) : token;
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of `token` whose size and tracking read as `ownFont.*` while a user font overrides its face, and as
|
||||
* the original literals otherwise. Getters, so every plan and layout that reads `token.size` sees the right
|
||||
* value without threading anything through; only the keys `ownFont` names become accessors.
|
||||
*/
|
||||
function followOverrides(token: TypeToken): TypeToken {
|
||||
const own = token.ownFont as NonNullable<TypeToken["ownFont"]>;
|
||||
const copy: TypeToken = { ...token };
|
||||
for (const key of ["size", "tracking"] as const) {
|
||||
const design = own[key];
|
||||
if (design === undefined) continue;
|
||||
const substituted = token[key];
|
||||
Object.defineProperty(copy, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (resolveFace(token.face).startsWith(USER_FAMILY_PREFIX) ? design : substituted),
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is
|
||||
* actually bundled. A user-imported font replaces the licensed name through `faceOverrides`.
|
||||
* Gotham runs about 4% narrower than Montserrat, so tokens that use it carry sizes already scaled by
|
||||
* GOTHAM_SIZE (see templates/northline); a real Gotham import would need them back at 1.
|
||||
* GOTHAM_SIZE (see templates/northline); a real Gotham import takes them back at 1 through TypeToken.ownFont.
|
||||
*/
|
||||
export const SUBSTITUTES: Readonly<Record<string, string>> = {
|
||||
Now: "Jost",
|
||||
@@ -78,8 +108,30 @@ export const SUBSTITUTES: Readonly<Record<string, string>> = {
|
||||
"Open Sauce One": "Poppins",
|
||||
};
|
||||
|
||||
/** Family names of user-imported fonts start with this (`user:Gotham:<set id>`). */
|
||||
export const USER_FAMILY_PREFIX = "user:";
|
||||
|
||||
type FaceOverrides = Readonly<Record<string, string>>;
|
||||
|
||||
let activeOverrides: FaceOverrides | undefined;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `overrides` as the default for every resolveFace call that is not given its own map. A render
|
||||
* sets this for its whole duration so templates, plans and the measurer agree on the faces without each of them
|
||||
* being handed the map. Renders are serialised (the worker's job lane, the main-thread client), so one slot is enough.
|
||||
*/
|
||||
export async function withFaceOverrides<T>(overrides: FaceOverrides | undefined, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = activeOverrides;
|
||||
activeOverrides = overrides && Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
activeOverrides = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/** The family name to register/look up for a face: user override first, then substitute, then itself. */
|
||||
export function resolveFace(face: string, overrides?: Readonly<Record<string, string>>): string {
|
||||
export function resolveFace(face: string, overrides: FaceOverrides | undefined = activeOverrides): string {
|
||||
return overrides?.[face] ?? SUBSTITUTES[face] ?? face;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The user-imported fonts that rendering knows about: Now, Gotham and Open Sauce One only. This module holds
|
||||
* plain data (no react-pdf), so the measurer can read it too. Registration with react-pdf is in register.ts.
|
||||
*
|
||||
* A face's fonts form a "set" (one file per weight and style) and each set is one react-pdf family named
|
||||
* `user:<Face>:<set id>`. The id comes from the files' hashes, so replacing a font gives a new family instead of
|
||||
* appending to the old one (react-pdf's font store only appends, and Font.reset() must not be called).
|
||||
*/
|
||||
import { USER_FAMILY_PREFIX } from "./roles";
|
||||
|
||||
/** The commercial template faces a user may import. Overrides for any other family are out of scope. */
|
||||
export const USER_FACES = ["Now", "Gotham", "Open Sauce One"] as const;
|
||||
export type UserFace = (typeof USER_FACES)[number];
|
||||
|
||||
export const isUserFace = (face: unknown): face is UserFace => USER_FACES.includes(face as UserFace);
|
||||
|
||||
/** What a font file looks like by its first four bytes. */
|
||||
export type FontFormat = "ttf" | "otf" | "woff" | "woff2" | "ttc" | "unknown";
|
||||
|
||||
export function sniffFontFormat(b: Uint8Array): FontFormat {
|
||||
if (b.length < 4) return "unknown";
|
||||
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "ttf";
|
||||
const tag = String.fromCharCode(b[0], b[1], b[2], b[3]);
|
||||
switch (tag) {
|
||||
case "true":
|
||||
return "ttf";
|
||||
case "OTTO":
|
||||
return "otf";
|
||||
case "wOFF":
|
||||
return "woff";
|
||||
case "wOF2":
|
||||
return "woff2";
|
||||
case "ttcf":
|
||||
return "ttc";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const REFUSALS: Partial<Record<FontFormat, string>> = {
|
||||
woff2: "WOFF2 fonts embed without glyph outlines (invisible text in the PDF). Use the TTF or OTF file.",
|
||||
woff: "A WOFF file must be converted to TTF/OTF first (the importer does this).",
|
||||
ttc: "Font collections (.ttc) are not supported. Use a single TTF or OTF file.",
|
||||
unknown: "This is not a TrueType or OpenType font file.",
|
||||
};
|
||||
|
||||
/** Throws unless the bytes are a plain TrueType or OpenType (sfnt) file; fontkit must never see anything else. */
|
||||
export function assertSfnt(b: Uint8Array): "ttf" | "otf" {
|
||||
const format = sniffFontFormat(b);
|
||||
if (format === "ttf" || format === "otf") return format;
|
||||
throw new Error(REFUSALS[format] ?? REFUSALS.unknown);
|
||||
}
|
||||
|
||||
/** One imported font file, as the renderer needs it. `sha256` identifies it (hex). */
|
||||
export interface UserFontFile {
|
||||
face: UserFace;
|
||||
weight: number;
|
||||
italic: boolean;
|
||||
sha256: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
const filesById = new Map<string, UserFontFile>();
|
||||
const familyFiles = new Map<string, UserFontFile[]>();
|
||||
|
||||
/** The family name of a face's set of files. Order does not matter. */
|
||||
export function familyNameFor(face: UserFace, ids: readonly string[]): string {
|
||||
return `${USER_FAMILY_PREFIX}${face}:${[...ids].sort().map((id) => id.slice(0, 8)).join("-")}`;
|
||||
}
|
||||
|
||||
export const hasUserFont = (id: string): boolean => filesById.has(id);
|
||||
export const getUserFontFile = (id: string): UserFontFile | undefined => filesById.get(id);
|
||||
export function rememberUserFile(file: UserFontFile): void {
|
||||
filesById.set(file.sha256, file);
|
||||
}
|
||||
|
||||
export function rememberUserFiles(family: string, files: UserFontFile[]): void {
|
||||
for (const f of files) filesById.set(f.sha256, f);
|
||||
familyFiles.set(family, files);
|
||||
}
|
||||
|
||||
export const isUserFamilyKnown = (family: string): boolean => familyFiles.has(family);
|
||||
|
||||
export interface UserFamilySource {
|
||||
id: string;
|
||||
fontWeight: number;
|
||||
fontStyle: "normal" | "italic";
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sources of a user family: its files, plus the upright file of each weight reused as italic when that weight
|
||||
* has no italic of its own (every registered family needs an italic, layout rule 8). Same rule as familySources.
|
||||
*/
|
||||
export function userFamilySources(family: string): UserFamilySource[] {
|
||||
const files = familyFiles.get(family);
|
||||
if (!files) return [];
|
||||
const sources: UserFamilySource[] = files.map((f) => ({
|
||||
id: f.sha256,
|
||||
fontWeight: f.weight,
|
||||
fontStyle: f.italic ? "italic" : "normal",
|
||||
bytes: f.bytes,
|
||||
}));
|
||||
for (const weight of new Set(sources.map((s) => s.fontWeight))) {
|
||||
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" });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** `prefs.fonts` (face -> ids of the files in use) as the face -> family map resolveFace takes. */
|
||||
export function faceOverridesFor(fonts: Partial<Record<UserFace, string[]>> | undefined): Record<string, string> | undefined {
|
||||
if (!fonts) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const face of USER_FACES) {
|
||||
const ids = fonts[face];
|
||||
if (!ids || ids.length === 0) continue;
|
||||
const missing = ids.filter((id) => !filesById.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`The font imported for ${face} is not loaded in the renderer (${missing.length} file(s) missing).`);
|
||||
}
|
||||
const family = familyNameFor(face, ids);
|
||||
if (!familyFiles.has(family)) throw new Error(`The font set for ${face} was not registered as a family.`);
|
||||
out[face] = family;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createElement as h } from "react";
|
||||
import { Document, Page, renderToBuffer, Text } from "@react-pdf/renderer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMeasurer } from "../engine/measure";
|
||||
import { DEFAULT_PREFS, parsePrefs } from "../model/prefs";
|
||||
import { registerUserFont, registerUserFontSet } from "./register";
|
||||
import { resolveFace, withFaceOverrides } from "./roles";
|
||||
import { assertSfnt, faceOverridesFor, sniffFontFormat } from "./userFontStore";
|
||||
import { CITRUS_ROLES } from "../templates/northline/citrus";
|
||||
import { PURPLE_ROLES } from "../templates/northline/purple";
|
||||
import { MARBLE_ROLES } from "../templates/marble/tokens";
|
||||
import { TEMPLATE_CATALOG } from "../templates/catalog";
|
||||
import { TEMPLATES } from "../templates/registry";
|
||||
import { ensureSetup, prefsFor, renderFixture } from "../templates/harness";
|
||||
import { gotham } from "../templates/northline/variant";
|
||||
import { FONTS_DIR } from "../testing/setup";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
const font = (folder: string, file: string) => new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
|
||||
const latin1 = (b: Uint8Array) => Buffer.from(b).toString("latin1");
|
||||
const SPEC = { name: "user-font", items: 3, logo: "none" as const };
|
||||
|
||||
describe("font sniffing and refusal", () => {
|
||||
it("recognises sfnt, WOFF, WOFF2 and collections by their magic bytes", () => {
|
||||
const tag = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)).concat([0, 0, 0, 0]));
|
||||
expect(sniffFontFormat(new Uint8Array([0, 1, 0, 0, 0, 0]))).toBe("ttf");
|
||||
expect(sniffFontFormat(tag("true"))).toBe("ttf");
|
||||
expect(sniffFontFormat(tag("OTTO"))).toBe("otf");
|
||||
expect(sniffFontFormat(tag("wOFF"))).toBe("woff");
|
||||
expect(sniffFontFormat(tag("wOF2"))).toBe("woff2");
|
||||
expect(sniffFontFormat(tag("ttcf"))).toBe("ttc");
|
||||
expect(sniffFontFormat(tag("<htm"))).toBe("unknown");
|
||||
expect(sniffFontFormat(new Uint8Array([0, 1]))).toBe("unknown");
|
||||
});
|
||||
|
||||
it("refuses WOFF2, WOFF, collections and non-fonts before fontkit sees them", async () => {
|
||||
const jost = font("jost", "Jost-Regular.ttf");
|
||||
expect(assertSfnt(jost)).toBe("ttf");
|
||||
const patched = (magic: string) => {
|
||||
const b = jost.slice();
|
||||
b.set([...magic].map((c) => c.charCodeAt(0)));
|
||||
return b;
|
||||
};
|
||||
expect(() => assertSfnt(patched("wOF2"))).toThrow(/WOFF2/);
|
||||
expect(() => assertSfnt(patched("ttcf"))).toThrow(/collections/);
|
||||
expect(() => assertSfnt(patched("wOFF"))).toThrow(/WOFF/);
|
||||
expect(() => assertSfnt(new TextEncoder().encode("<!doctype html>"))).toThrow(/not a TrueType/);
|
||||
await expect(registerUserFont("Gotham", patched("wOF2"))).rejects.toThrow(/WOFF2/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefs carry user fonts", () => {
|
||||
it("keeps valid face/hash lists and drops everything else", () => {
|
||||
const sha = "a".repeat(64);
|
||||
const prefs = parsePrefs(JSON.stringify({ version: 1, templateId: "marble", fonts: { Now: [sha, "zz", 5], Gotham: [], Poppins: [sha], "Open Sauce One": "nope" } }));
|
||||
expect(prefs.fonts).toEqual({ Now: [sha] });
|
||||
expect(parsePrefs(JSON.stringify({ version: 1 })).fonts).toBeUndefined();
|
||||
expect(parsePrefs("{}")).toEqual(DEFAULT_PREFS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("override compensation", () => {
|
||||
it("leaves the tuned values alone without an override", () => {
|
||||
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
|
||||
expect(CITRUS_ROLES.tableBody.size).toBe(gotham(12));
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
|
||||
expect(Object.keys(MARBLE_ROLES.body)).toContain("tracking");
|
||||
});
|
||||
|
||||
it("takes the design size and tracking back only while a user font replaces the face", async () => {
|
||||
await withFaceOverrides({ Gotham: "user:Gotham:x", Now: "user:Now:x" }, async () => {
|
||||
expect(PURPLE_ROLES.label.size).toBe(12);
|
||||
expect(CITRUS_ROLES.tableBody.size).toBe(12);
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
|
||||
expect({ ...MARBLE_ROLES.body }.tracking).toBe(0.04);
|
||||
// A face that is not overridden keeps its tuned values (Open Sauce One stays Poppins).
|
||||
expect(PURPLE_ROLES.wordmark.size).toBe(94.07);
|
||||
});
|
||||
await withFaceOverrides({ Now: "user:Now:x" }, async () => {
|
||||
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.04);
|
||||
});
|
||||
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
|
||||
expect(MARBLE_ROLES.body.tracking).toBe(0.105);
|
||||
expect(resolveFace("Gotham")).toBe("Montserrat");
|
||||
});
|
||||
|
||||
it("the catalog lists exactly the licensed faces each template's roles use", () => {
|
||||
for (const entry of TEMPLATE_CATALOG) {
|
||||
const used = new Set(Object.values(TEMPLATES[entry.id].roles).map((t) => t.face).filter((f) => f === "Now" || f === "Gotham" || f === "Open Sauce One"));
|
||||
expect([...(entry.faces ?? [])].sort(), entry.id).toEqual([...used].sort());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a user font replaces a template face", () => {
|
||||
it("embeds the imported font in a Purple Pop invoice, and only then", async () => {
|
||||
const lustria = font("lustria", "Lustria-Regular.ttf");
|
||||
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
|
||||
expect(latin1(plain.result.bytes)).not.toContain("Lustria");
|
||||
expect(plain.result.bytes.length).toBeGreaterThan(1000);
|
||||
|
||||
const { family, sha256 } = await registerUserFont("Gotham", lustria);
|
||||
expect(family).toMatch(/^user:Gotham:[0-9a-f]{8}$/);
|
||||
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: [sha256] } }));
|
||||
expect(latin1(over.result.bytes)).toContain("Lustria");
|
||||
expect(over.result.fingerprint).not.toBe(plain.result.fingerprint);
|
||||
expect(over.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
|
||||
// The override ended with the render: the same prefs without fonts give the original fingerprint again.
|
||||
const after = await renderFixture(SPEC, prefsFor("A4", { templateId: "purple-pop" }));
|
||||
expect(after.result.fingerprint).toBe(plain.result.fingerprint);
|
||||
}, 60_000);
|
||||
|
||||
it("a Marble invoice with Now replaced by a user font lays out and embeds it", async () => {
|
||||
const { sha256 } = await registerUserFont("Now", font("dm-sans", "DMSans-Regular.ttf"));
|
||||
const plain = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble" }));
|
||||
const over = await renderFixture(SPEC, prefsFor("A4", { templateId: "marble", fonts: { Now: [sha256] } }));
|
||||
expect(latin1(over.result.bytes)).toContain("DMSans");
|
||||
expect(latin1(plain.result.bytes)).not.toContain("DMSans");
|
||||
expect(over.pdf.pages[0].items.map((i) => i.str).join(" ")).toContain("₹");
|
||||
}, 60_000);
|
||||
|
||||
it("fails clearly when prefs name a font the renderer was never given", async () => {
|
||||
const prefs = prefsFor("A4", { templateId: "purple-pop", fonts: { Gotham: ["f".repeat(64)] } });
|
||||
await expect(renderFixture(SPEC, prefs)).rejects.toThrow(/not loaded/);
|
||||
});
|
||||
|
||||
it("every weight gets an italic: a set registers its upright files as italics, and italic text does not throw", async () => {
|
||||
const regular = font("montserrat", "Montserrat-Regular.ttf");
|
||||
const bold = font("montserrat", "Montserrat-Bold.ttf");
|
||||
const family = await registerUserFontSet("Open Sauce One", [
|
||||
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
|
||||
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
|
||||
]);
|
||||
const render = (fontWeight: number) =>
|
||||
renderToBuffer(h(Document, null, h(Page, { size: "A4" }, h(Text, { style: { fontFamily: [family], fontStyle: "italic", fontWeight } }, "Total ₹1,200.00"))));
|
||||
for (const w of [400, 700]) expect((await render(w)).subarray(0, 5).toString()).toBe("%PDF-");
|
||||
// Registering the same set again is a no-op.
|
||||
expect(await registerUserFontSet("Open Sauce One", [
|
||||
{ face: "Open Sauce One", weight: 700, italic: false, sha256: "2".repeat(16), bytes: bold },
|
||||
{ face: "Open Sauce One", weight: 400, italic: false, sha256: "1".repeat(16), bytes: regular },
|
||||
])).toBe(family);
|
||||
expect(faceOverridesFor({ "Open Sauce One": ["1".repeat(16), "2".repeat(16)] })).toEqual({ "Open Sauce One": family });
|
||||
});
|
||||
|
||||
it("the measurer reads user families from memory, bold and italic included", async () => {
|
||||
const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file))));
|
||||
const poppins = font("poppins", "Poppins-Regular.ttf");
|
||||
const { family } = await registerUserFont("Gotham", poppins, { weight: 400 });
|
||||
const own = m.measureWidth({ text: "Consultation", face: family, weight: 400, size: 12 });
|
||||
const plex = m.measureWidth({ text: "Consultation", face: "Poppins", weight: 400, size: 12 });
|
||||
expect(own).toBeCloseTo(plex, 6);
|
||||
expect(m.measureWidth({ text: "Consultation", face: family, weight: 700, italic: true, size: 12 })).toBeCloseTo(plex, 6);
|
||||
expect(m.missing("₹", family)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MarginPreset, PageId } from "../engine/geometry";
|
||||
import { isUserFace, type UserFace } from "../fonts/userFontStore";
|
||||
|
||||
/** Page and template choices frozen with an invoice at issue (stored as JSON). */
|
||||
export interface RenderPrefsV1 {
|
||||
@@ -12,6 +13,11 @@ export interface RenderPrefsV1 {
|
||||
/** Templates without a reference logo placeholder (Linea, ...) show the logo only when this is true. */
|
||||
show?: boolean;
|
||||
};
|
||||
/**
|
||||
* User-imported fonts in use, per face (Now, Gotham, Open Sauce One): the sha256 of each file (one per weight/style).
|
||||
* Frozen with the invoice, which is what keeps those font files from being deleted. Absent: the bundled substitutes.
|
||||
*/
|
||||
fonts?: Partial<Record<UserFace, string[]>>;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFS: RenderPrefsV1 = {
|
||||
@@ -22,6 +28,7 @@ export const DEFAULT_PREFS: RenderPrefsV1 = {
|
||||
};
|
||||
|
||||
const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"];
|
||||
const FONT_ID = /^[0-9a-f]{16,64}$/;
|
||||
const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"];
|
||||
|
||||
/** Tolerant: anything unreadable or out of range falls back to the default for that field. */
|
||||
@@ -49,6 +56,16 @@ export function parsePrefs(json: string | null | undefined): RenderPrefsV1 {
|
||||
if (typeof show === "boolean") out.show = show;
|
||||
if (Object.keys(out).length > 0) prefs.logo = out;
|
||||
}
|
||||
const fonts = o.fonts;
|
||||
if (typeof fonts === "object" && fonts !== null) {
|
||||
const out: NonNullable<RenderPrefsV1["fonts"]> = {};
|
||||
for (const [face, ids] of Object.entries(fonts as Record<string, unknown>)) {
|
||||
if (!isUserFace(face) || !Array.isArray(ids)) continue;
|
||||
const valid = ids.filter((id): id is string => typeof id === "string" && FONT_ID.test(id)).slice(0, 16);
|
||||
if (valid.length > 0) out[face] = valid;
|
||||
}
|
||||
if (Object.keys(out).length > 0) prefs.fonts = out;
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class FakeWorker implements WorkerLike {
|
||||
onerror: WorkerLike["onerror"] = null;
|
||||
sent: WorkerRequest[] = [];
|
||||
terminated = false;
|
||||
postMessage(message: WorkerRequest) {
|
||||
postMessage(message: WorkerRequest, _transfer?: Transferable[]) {
|
||||
this.sent.push(message);
|
||||
}
|
||||
terminate() {
|
||||
@@ -378,3 +378,94 @@ describe("main-thread client and fallback", () => {
|
||||
expect((await client.render(job(8))).id).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user fonts in render jobs", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const file = (sha: string, bytes: number[]) => ({ face: "Gotham" as const, weight: 400, italic: false, sha256: sha, bytes: new Uint8Array(bytes) });
|
||||
const withFonts = (id: number, files: ReturnType<typeof file>[]): RenderJob => ({
|
||||
id,
|
||||
model: {} as RenderJob["model"],
|
||||
prefs: { fonts: { Gotham: files.map((f) => f.sha256) } } as unknown as RenderJob["prefs"],
|
||||
userFonts: files,
|
||||
});
|
||||
|
||||
it("sends each font to a worker once, as transferred copies, and again to a replacement worker", async () => {
|
||||
const posts: Array<{ job: RenderJob; transfer: Transferable[] | undefined }> = [];
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = createRenderClient({
|
||||
timeoutMs: 1000,
|
||||
workerFactory: () => {
|
||||
const w = new FakeWorker();
|
||||
w.postMessage = (message: WorkerRequest, transfer?: Transferable[]) => {
|
||||
posts.push({ job: (message as { job: RenderJob }).job, transfer });
|
||||
w.sent.push(message);
|
||||
};
|
||||
workers.push(w);
|
||||
return w;
|
||||
},
|
||||
});
|
||||
const master = file("a".repeat(16), [1, 2, 3]);
|
||||
const p1 = client.render(withFonts(1, [master]));
|
||||
workers[0].ready();
|
||||
expect(posts[0].job.userFonts?.map((f) => f.sha256)).toEqual([master.sha256]);
|
||||
expect(posts[0].job.userFonts?.[0].bytes).not.toBe(master.bytes);
|
||||
expect(posts[0].transfer).toHaveLength(1);
|
||||
expect(Array.from(master.bytes)).toEqual([1, 2, 3]);
|
||||
workers[0].reply(1);
|
||||
await p1;
|
||||
|
||||
const extra = file("b".repeat(16), [9]);
|
||||
const p2 = client.render(withFonts(2, [master, extra]));
|
||||
expect(posts[1].job.userFonts?.map((f) => f.sha256)).toEqual([extra.sha256]);
|
||||
workers[0].reply(2);
|
||||
await p2;
|
||||
|
||||
// A worker replaced after a timeout starts with nothing, so every font goes again.
|
||||
const p3 = client.render(withFonts(3, [master]));
|
||||
workers[0].reply(3);
|
||||
await p3;
|
||||
const p4 = client.render(withFonts(4, [master, extra]));
|
||||
const timedOut = settled(p4);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(await timedOut).toBeInstanceOf(RenderTimeout);
|
||||
expect(workers).toHaveLength(2);
|
||||
const p5 = client.render(withFonts(5, [master, extra]));
|
||||
workers[1].ready();
|
||||
expect(posts[posts.length - 1].job.userFonts?.map((f) => f.sha256).sort()).toEqual([master.sha256, extra.sha256]);
|
||||
workers[1].reply(5);
|
||||
await p5;
|
||||
});
|
||||
|
||||
it("the auto client attaches the bytes from the loader and reads each file once", async () => {
|
||||
vi.useRealTimers();
|
||||
const reads: string[] = [];
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = createAutoClient({
|
||||
workerFactory: () => {
|
||||
const w = new FakeWorker();
|
||||
workers.push(w);
|
||||
return w;
|
||||
},
|
||||
loadUserFont: async (sha) => {
|
||||
reads.push(sha);
|
||||
return file(sha, [7]);
|
||||
},
|
||||
});
|
||||
const prefs = { fonts: { Gotham: ["c".repeat(16)] } } as unknown as RenderJob["prefs"];
|
||||
const p1 = client.render({ id: 1, model: {} as RenderJob["model"], prefs });
|
||||
await vi.waitFor(() => expect(workers).toHaveLength(1));
|
||||
workers[0].ready();
|
||||
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(1));
|
||||
expect((workers[0].sent[0] as { job: RenderJob }).job.userFonts?.map((f) => f.sha256)).toEqual(["c".repeat(16)]);
|
||||
workers[0].reply(1);
|
||||
await p1;
|
||||
const p2 = client.render({ id: 2, model: {} as RenderJob["model"], prefs });
|
||||
await vi.waitFor(() => expect(workers[0].sent).toHaveLength(2));
|
||||
expect((workers[0].sent[1] as { job: RenderJob }).job.userFonts).toEqual([]);
|
||||
workers[0].reply(2);
|
||||
await p2;
|
||||
expect(reads).toEqual(["c".repeat(16)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UserFontFile } from "../fonts/userFontStore";
|
||||
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
/** A newer render call replaced this one before it finished (or before it started). */
|
||||
@@ -35,7 +36,7 @@ export class RenderFailed extends Error {
|
||||
|
||||
/** The part of Worker the client uses, so tests can supply a fake. */
|
||||
export interface WorkerLike {
|
||||
postMessage(message: WorkerRequest): void;
|
||||
postMessage(message: WorkerRequest, transfer?: Transferable[]): void;
|
||||
terminate(): void;
|
||||
onmessage: ((event: { data: WorkerMessage }) => void) | null;
|
||||
onerror: ((event: { message?: string }) => void) | null;
|
||||
@@ -55,6 +56,11 @@ export interface RenderClient {
|
||||
}
|
||||
|
||||
export interface RenderClientOptions {
|
||||
/**
|
||||
* Returns a user font file by its sha256 (the app reads it from the database). Needed only for jobs whose
|
||||
* prefs name user fonts; the result is cached, so each file is read once per session.
|
||||
*/
|
||||
loadUserFont?: (sha256: string) => Promise<UserFontFile>;
|
||||
workerFactory?: () => WorkerLike;
|
||||
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
|
||||
timeoutMs?: number;
|
||||
@@ -85,8 +91,21 @@ type Running = Slot & {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const requestFor = (slot: Slot): WorkerRequest =>
|
||||
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job };
|
||||
/**
|
||||
* The message for a slot plus what to transfer. A render's user fonts are cut down to those this worker has not
|
||||
* been sent (`sent`), and each goes as a transferred copy: the caller's bytes are a master copy that must survive
|
||||
* a worker restart, and a transferred buffer is detached on this side.
|
||||
*/
|
||||
function requestFor(slot: Slot, sent: Set<string>): { request: WorkerRequest; transfer: Transferable[]; ids: string[] } {
|
||||
if (slot.kind !== "render") return { request: { type: "assemble-images", job: slot.job }, transfer: [], ids: [] };
|
||||
const fresh = (slot.job.userFonts ?? []).filter((f) => !sent.has(f.sha256));
|
||||
const copies = fresh.map((f) => ({ ...f, bytes: f.bytes.slice() }));
|
||||
return {
|
||||
request: { type: "render", job: { ...slot.job, userFonts: copies } },
|
||||
transfer: copies.map((f) => f.bytes.buffer),
|
||||
ids: fresh.map((f) => f.sha256),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
|
||||
@@ -105,11 +124,14 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
||||
let running: Running | null = null;
|
||||
let pending: RenderSlot | null = null;
|
||||
const assembles: AssembleSlot[] = [];
|
||||
/** Ids of the user font files the current worker has been sent; a new worker starts with none. */
|
||||
const sent = new Set<string>();
|
||||
|
||||
const stopWorker = () => {
|
||||
const w = worker;
|
||||
worker = null;
|
||||
ready = false;
|
||||
sent.clear();
|
||||
if (w) {
|
||||
w.onmessage = null;
|
||||
w.onerror = null;
|
||||
@@ -147,7 +169,9 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
|
||||
}
|
||||
if (!slot) return;
|
||||
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
|
||||
worker.postMessage(requestFor(slot));
|
||||
const { request, transfer, ids } = requestFor(slot, sent);
|
||||
worker.postMessage(request, transfer);
|
||||
for (const id of ids) sent.add(id);
|
||||
};
|
||||
|
||||
const spawn = () => {
|
||||
@@ -239,8 +263,13 @@ export type MainExecutor = (job: RenderJob) => Promise<RenderResult>;
|
||||
|
||||
// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable.
|
||||
const runOnMainThread: MainExecutor = async (job) => {
|
||||
const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]);
|
||||
const [{ initBrowserRendering }, { renderInvoicePdf }, { registerJobFonts }] = await Promise.all([
|
||||
import("./browserInit"),
|
||||
import("./invoice"),
|
||||
import("./userFontJob"),
|
||||
]);
|
||||
await initBrowserRendering();
|
||||
await registerJobFonts(job.prefs, job.userFonts);
|
||||
// Let pending input and paint run before the long synchronous layout.
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
const t0 = performance.now();
|
||||
@@ -337,6 +366,27 @@ export function createAutoClient(
|
||||
): RenderClient {
|
||||
const primary = createRenderClient(opts);
|
||||
let main: RenderClient | null = null;
|
||||
const fontCache = new Map<string, Promise<UserFontFile>>();
|
||||
let renderCalls = 0;
|
||||
/** Attaches the bytes of the user fonts the job's prefs name (read once, then cached). */
|
||||
const withFonts = async (job: RenderJob): Promise<RenderJob> => {
|
||||
const ids = Object.values(job.prefs.fonts ?? {}).flat();
|
||||
if (ids.length === 0) return job;
|
||||
const load = opts.loadUserFont;
|
||||
if (!load) throw new Error("This render needs user fonts but no font loader is configured");
|
||||
const files = await Promise.all(
|
||||
ids.map((id) => {
|
||||
let p = fontCache.get(id);
|
||||
if (!p) {
|
||||
p = load(id);
|
||||
fontCache.set(id, p);
|
||||
p.catch(() => fontCache.delete(id));
|
||||
}
|
||||
return p;
|
||||
}),
|
||||
);
|
||||
return { ...job, userFonts: files };
|
||||
};
|
||||
const fallback = () => {
|
||||
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
|
||||
primary.dispose();
|
||||
@@ -346,7 +396,11 @@ export function createAutoClient(
|
||||
get mode() {
|
||||
return main ? "main" : "worker";
|
||||
},
|
||||
render(job) {
|
||||
async render(plain) {
|
||||
const call = ++renderCalls;
|
||||
const job = await withFonts(plain);
|
||||
// Reading fonts took time; keep latest-wins: a render requested meanwhile replaces this one.
|
||||
if (call !== renderCalls) throw new Superseded();
|
||||
if (main) return main.render(job);
|
||||
return primary.render(job).catch((err) => {
|
||||
if (!(err instanceof WorkerUnavailable)) throw err;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
import { withFaceOverrides } from "../fonts/roles";
|
||||
import { faceOverridesFor } from "../fonts/userFontStore";
|
||||
import { CLOSING_ID } from "../engine/ids";
|
||||
import { marginsFor, type PlacedLogo } from "../templates/contract";
|
||||
import { getTemplate } from "../templates/registry";
|
||||
@@ -41,7 +43,12 @@ export function frameForPrefs(prefs: RenderPrefsV1): PageFrame {
|
||||
* model + prefs -> template, frame, logo placement and keep-outs -> InvoicePdf -> renderCore. Needs
|
||||
* fonts registered and a measurer installed (render/browserInit.ts in the app, testing/setup.ts in tests).
|
||||
*/
|
||||
export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
|
||||
export function renderInvoicePdf(input: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
|
||||
// prefs.fonts names the user fonts (already registered by the caller) that replace the substitute faces.
|
||||
return withFaceOverrides(faceOverridesFor(input.prefs.fonts), () => renderInvoicePdfWith(input, bytesOf));
|
||||
}
|
||||
|
||||
async function renderInvoicePdfWith({ model, prefs }: RenderInvoiceInput, bytesOf?: RenderBytes): Promise<RenderInvoiceResult> {
|
||||
const template = getTemplate(prefs.templateId);
|
||||
const frame = frameForPrefs(prefs);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import type { RenderModel } from "../model/build";
|
||||
import type { UserFontFile } from "../fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
|
||||
@@ -8,6 +9,11 @@ export interface RenderJob {
|
||||
id: number;
|
||||
model: RenderModel;
|
||||
prefs: RenderPrefsV1;
|
||||
/**
|
||||
* Bytes of the user fonts `prefs.fonts` names. The caller attaches all of them; the worker client sends only the
|
||||
* ones that worker does not hold yet (as transferred copies, so the caller's master copy stays usable).
|
||||
*/
|
||||
userFonts?: UserFontFile[];
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
|
||||
import { initBrowserRendering } from "./browserInit";
|
||||
import { runAssembleJob } from "./assembleJob";
|
||||
import { renderInvoicePdf } from "./invoice";
|
||||
import { registerJobFonts } from "./userFontJob";
|
||||
import type { WorkerMessage, WorkerRequest } from "./jobs";
|
||||
|
||||
interface WorkerScope {
|
||||
@@ -44,6 +45,7 @@ scope.onmessage = async (event) => {
|
||||
const { job } = request;
|
||||
try {
|
||||
await started.catch(() => initBrowserRendering());
|
||||
await registerJobFonts(job.prefs, job.userFonts);
|
||||
const t0 = performance.now();
|
||||
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { registerUserFontSet } from "../fonts/register";
|
||||
import { getUserFontFile, rememberUserFile, USER_FACES, type UserFontFile } from "../fonts/userFontStore";
|
||||
import type { RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
/**
|
||||
* Makes the user fonts a render's prefs name available to react-pdf and the measurer, before the render runs.
|
||||
* `incoming` are the files the client sent with this job (only those this renderer has not been sent yet); the
|
||||
* others are already held from earlier jobs. Registering a set is idempotent.
|
||||
*/
|
||||
export async function registerJobFonts(prefs: RenderPrefsV1, incoming: readonly UserFontFile[] = []): Promise<void> {
|
||||
for (const file of incoming) rememberUserFile(file);
|
||||
for (const face of USER_FACES) {
|
||||
const ids = prefs.fonts?.[face];
|
||||
if (!ids || ids.length === 0) continue;
|
||||
const files = ids.map((id) => getUserFontFile(id));
|
||||
if (files.some((f) => !f)) throw new Error(`The font imported for ${face} was not delivered to the renderer`);
|
||||
await registerUserFontSet(face, files as UserFontFile[]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export interface TemplateCatalogEntry {
|
||||
badge?: "Matches your original format";
|
||||
/** No reference placeholder: the logo shows only when the user turns it on. */
|
||||
logoOptIn?: boolean;
|
||||
/**
|
||||
* The licensed faces this template draws (a user-imported font may replace them). Mirrors the roles of the
|
||||
* template; a test keeps the two in step.
|
||||
*/
|
||||
faces?: readonly ("Now" | "Gotham" | "Open Sauce One")[];
|
||||
}
|
||||
|
||||
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
|
||||
@@ -32,11 +37,11 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
|
||||
{ id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" },
|
||||
{ id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" },
|
||||
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
|
||||
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true },
|
||||
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
|
||||
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true, faces: ["Gotham", "Open Sauce One"] },
|
||||
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png", faces: ["Now", "Gotham"] },
|
||||
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" },
|
||||
{ id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" },
|
||||
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true },
|
||||
{ id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true, faces: ["Now"] },
|
||||
{ id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -339,6 +339,25 @@ describe("closing and footer", () => {
|
||||
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
|
||||
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 7]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
||||
const where = (id: string) => {
|
||||
const pages: number[] = [];
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
|
||||
return pages;
|
||||
};
|
||||
const rowPages = new Set<number>();
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
|
||||
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
|
||||
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
|
||||
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
|
||||
expect(r.result.pages).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
|
||||
@@ -325,13 +325,17 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
|
||||
const nominalTotal = nominalFor(tailBody);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
|
||||
// page 1 and let only the closing block move to the next page.
|
||||
// page 1 and let only the closing block move to the next page (its own gaps stay nominal: squeezing them would take
|
||||
// a share of the deficit away from the gaps that do help page 1).
|
||||
const overflowBeforeClosing = nominalTotal - nominalClosing.h - frame.contentHeight;
|
||||
const { terms: termsRange, footer: footerRange, ...beforeRanges } = ranges;
|
||||
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
|
||||
const air = (over: number) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
|
||||
let g: Record<"client" | "table" | "tail" | "terms" | "footer", number>;
|
||||
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
|
||||
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), terms: termsRange.nominal, footer: footerRange.nominal };
|
||||
else g = elastic(ranges, frame.heightDeficit);
|
||||
const closing = closingFor(g);
|
||||
const tailH = g.tail + tailBody;
|
||||
const table = planTable(tailH);
|
||||
|
||||
@@ -18,31 +18,32 @@ export const BAND_TO = "#ECEAEE";
|
||||
* Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the
|
||||
* amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now,
|
||||
* so every Now role carries the tracking that restores the reference widths (0.10 to 0.13 em on top of the design's
|
||||
* 0.04 em). Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
|
||||
* 0.04 em). A real Now imported by the user replaces Jost, so those roles take the design's 0.04 em back through
|
||||
* ownFont. Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375.
|
||||
*/
|
||||
export const MARBLE_ROLES = defineRoles({
|
||||
/** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */
|
||||
title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 },
|
||||
/** The vendor's name at the header's left (the design leaves it empty). */
|
||||
brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 },
|
||||
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, minSize: 8 },
|
||||
meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
|
||||
/** BILL TO:, PAYABLE TO:, SUPPLIER:. */
|
||||
heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true },
|
||||
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, minSize: 8 },
|
||||
body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, ownFont: { tracking: 0.04 }, minSize: 8 },
|
||||
tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true },
|
||||
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 },
|
||||
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 },
|
||||
tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102, ownFont: { tracking: 0.04 } },
|
||||
tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1, ownFont: { tracking: 0.04 } },
|
||||
totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true },
|
||||
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102 },
|
||||
totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102, ownFont: { tracking: 0.04 } },
|
||||
/** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */
|
||||
due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 },
|
||||
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13 },
|
||||
small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 } },
|
||||
smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true },
|
||||
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 },
|
||||
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 },
|
||||
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 },
|
||||
signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102, ownFont: { tracking: 0.04 } },
|
||||
signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13, ownFont: { tracking: 0.04 } },
|
||||
fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1, ownFont: { tracking: 0.04 } },
|
||||
/** The footer band's contact lines. */
|
||||
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, minSize: 7 },
|
||||
contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, ownFont: { tracking: 0.04 }, minSize: 7 },
|
||||
footer: { face: N, weight: 400, size: 7 },
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
|
||||
import { atRate, LABEL_GAP, NOTES_FLOW_GAP, paymentRows, planMonolith, REVERSE_GAP, roleOf, signatureOptions, type MonolithPlan } from "./plan";
|
||||
import type { MonolithVariant } from "./tokens";
|
||||
|
||||
/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */
|
||||
const NOTES_HOLD = 70;
|
||||
|
||||
/** Layout-tree ids of the blocks that are Monolith's own (the harness ids are in classic/ids.ts). */
|
||||
export const BRAND_ID = "monolith-brand";
|
||||
export const IDS_ID = "monolith-ids";
|
||||
@@ -348,10 +351,15 @@ export function MonolithBody({ model, frame, logo, prefs, variant: v }: Template
|
||||
const plan = planMonolith(model, frame, v, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v, logo };
|
||||
const r = v.roles;
|
||||
const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
const notesFlow: ReactNode =
|
||||
plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
<>
|
||||
{/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */}
|
||||
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP }}>
|
||||
<Notes ctx={ctx} width={plan.W} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -286,6 +286,41 @@ describe("long text", () => {
|
||||
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("when only the closing block moves to page 2 the page-1 gaps are squeezed enough to keep the items and totals on page 1", async () => {
|
||||
// Row counts where the overflow before the closing block is within the gaps that help page 1 (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 4], ["A4", 5], ["LETTER", 4]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ name: "s", items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
||||
const where = (id: string) => {
|
||||
const pages: number[] = [];
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id === id && pages.push(i)));
|
||||
return pages;
|
||||
};
|
||||
const rowPages = new Set<number>();
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x.id?.startsWith(TABLE_ROW_PREFIX) && rowPages.add(i)));
|
||||
expect([...rowPages], `${size} n=${n} rows`).toEqual([0]);
|
||||
expect(where(TOTALS_ID), `${size} n=${n} totals`).toEqual([0]);
|
||||
expect(where(CLOSING_ID), `${size} n=${n} closing`).toEqual([1]);
|
||||
expect(r.result.pages).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("flow notes never start with less than a line of room: the block starts on the next page instead of collapsing into zero-height lines", async () => {
|
||||
const base = FIXTURES.find((f) => f.name === "long-notes")!;
|
||||
// Row counts where the notes' label used to land in the last few points of a page (A4 / Letter).
|
||||
const cases: Array<["A4" | "LETTER", number]> = [["A4", 3], ["A4", 14], ["LETTER", 13]];
|
||||
for (const [size, n] of cases) {
|
||||
const r = await renderFixture({ ...base, items: n, logo: "none" }, prefsFor(size, { templateId: "monolith" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), `${size} n=${n}`).toEqual([]);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const label = nodes(r.result.core.layout).find((x) => x.type === "text" && x.lines?.some((l) => /^notes/i.test(l.text)))!;
|
||||
let page = -1;
|
||||
r.result.core.layout.forEach((pg, i) => walk(pg.nodes, (x) => x === label && (page = i)));
|
||||
expect(label.box.y + 70, `${size} n=${n} label room`).toBeLessThanOrEqual(frame.page.height - frame.pad.bottom);
|
||||
expect(page).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
|
||||
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const brand = textLines(byId(r, BRAND_ID));
|
||||
|
||||
@@ -235,15 +235,20 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
|
||||
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
|
||||
const nominalTotal = headH + v.headRuleGap + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailH + notesBlock + closingNominal;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
// The closing block's own gaps cannot help page 1 once it has moved to page 2.
|
||||
const { closeBand: closeBandRange, closeBottom: closeBottomRange, ...beforeRanges } = ranges;
|
||||
const slackBefore = Object.values(beforeRanges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
|
||||
// page 1 and let only the closing block move to the next page.
|
||||
// page 1 and let only the closing block move to the next page (its own gaps stay nominal: squeezing them would take
|
||||
// a share of the deficit away from the gaps that do help page 1).
|
||||
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
|
||||
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
|
||||
const air = (over: number) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const air = (over: number, room: number) => Math.min(room, over + FIT_AIR);
|
||||
let g: Record<"band" | "infoTop" | "infoBottom" | "closeBand" | "closeBottom" | "rowPad", number>;
|
||||
if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack)));
|
||||
else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), closeBand: closeBandRange.nominal, closeBottom: closeBottomRange.nominal };
|
||||
else g = elastic(ranges, frame.heightDeficit);
|
||||
const closingH = closingFor(g);
|
||||
const padY = n ? g.rowPad / (2 * n) : padYn;
|
||||
const table = planTable(padY, tailH);
|
||||
|
||||
@@ -28,21 +28,21 @@ export const CITRUS_ROLES = defineRoles({
|
||||
metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 },
|
||||
billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true },
|
||||
clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.5), leading: 15.5 },
|
||||
headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true },
|
||||
headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true },
|
||||
tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.5), ownFont: { size: 10.5 }, leading: 15.5 },
|
||||
headService: { face: G, weight: 700, size: gotham(15.17), ownFont: { size: 15.17 }, leading: 18, tracking: 0.15, caps: true },
|
||||
headNum: { face: G, weight: 700, size: gotham(14.17), ownFont: { size: 14.17 }, leading: 18, caps: true },
|
||||
tableBody: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 15 },
|
||||
tableSub: { face: G, weight: 400, size: 9, leading: 11.5 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(12.5), ownFont: { size: 12.5 }, leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(14.7), ownFont: { size: 14.7 }, leading: 18 },
|
||||
words: { face: DM, weight: 700, size: 11, leading: 14 },
|
||||
wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 },
|
||||
sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true },
|
||||
bank: { face: G, weight: 400, size: gotham(11), leading: 15 },
|
||||
bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 15 },
|
||||
bullet: { face: G, weight: 400, size: gotham(12), ownFont: { size: 12 }, leading: 16.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(12.77), ownFont: { size: 12.77 }, leading: 18, minSize: 7 },
|
||||
small: { face: DM, weight: 400, size: 9, leading: 12 },
|
||||
fine: { face: DM, weight: 400, size: 7, leading: 10 },
|
||||
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
|
||||
|
||||
@@ -22,25 +22,25 @@ export const PURPLE_ROLES = defineRoles({
|
||||
wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 },
|
||||
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
|
||||
brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 },
|
||||
pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
|
||||
label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true },
|
||||
clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.5 },
|
||||
pill: { face: G, weight: 700, size: gotham(13.39), ownFont: { size: 13.39 }, leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
|
||||
label: { face: G, weight: 700, size: gotham(12), ownFont: { size: 12 }, leading: 16, caps: true },
|
||||
clientName: { face: G, weight: 700, size: gotham(14.39), ownFont: { size: 14.39 }, leading: 18, caps: true },
|
||||
clientLine: { face: G, weight: 400, size: gotham(10.16), ownFont: { size: 10.16 }, leading: 15.5 },
|
||||
amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true },
|
||||
date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 },
|
||||
tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true },
|
||||
tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 },
|
||||
tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 },
|
||||
totalLabel: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
|
||||
totalValue: { face: G, weight: 400, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
|
||||
grand: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17, caps: true },
|
||||
grandValue: { face: G, weight: 700, size: gotham(13), ownFont: { size: 13 }, leading: 17 },
|
||||
words: { face: DM, weight: 400, size: 10, leading: 13 },
|
||||
wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 },
|
||||
termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true },
|
||||
bullet: { face: DM, weight: 400, size: 11, leading: 14.2 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 },
|
||||
bank: { face: G, weight: 400, size: gotham(11), ownFont: { size: 11 }, leading: 12.5 },
|
||||
contact: { face: G, weight: 400, size: gotham(10.14), ownFont: { size: 10.14 }, leading: 15, minSize: 7 },
|
||||
small: { face: DM, weight: 400, size: 9, leading: 12 },
|
||||
fine: { face: DM, weight: 400, size: 7, leading: 10 },
|
||||
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { GapRange } from "../neutral/variant";
|
||||
/**
|
||||
* Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived
|
||||
* size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real
|
||||
* Gotham imported over the licensed name would need these tokens at factor 1.
|
||||
* Gotham imported over the licensed name takes the design size back: each such token carries it as `ownFont.size`.
|
||||
*/
|
||||
export const GOTHAM_SIZE = 0.96;
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { emailError, ifscError, panError, phoneError, vendorGstinError } from ".
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { LogoBranding } from "../components/LogoBranding";
|
||||
import DiagnosticsPanel from "../components/DiagnosticsPanel";
|
||||
import { FontOverrideTable } from "../components/FontOverrideTable";
|
||||
|
||||
const blankBank = (): BankAccount => ({
|
||||
id: null,
|
||||
@@ -289,6 +290,13 @@ export default function AppSettings({
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Fonts</h3>
|
||||
<Tile>
|
||||
<FontOverrideTable />
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Defaults</h3>
|
||||
<Grid narrow>
|
||||
|
||||
@@ -64,6 +64,8 @@ import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
|
||||
import { loadExportMode } from "../lib/exportMode";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||
import { useUserFonts } from "../hooks/useUserFonts";
|
||||
import { withUserFonts } from "../lib/fontImport";
|
||||
import { ImagePicker } from "../components/ImagePicker";
|
||||
import { loadLogoImages, type LogoImages } from "../lib/logo";
|
||||
|
||||
@@ -354,7 +356,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
// Page setup for the invoice being edited; an issued invoice shows what was frozen at issue.
|
||||
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs));
|
||||
useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]);
|
||||
const activePrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
|
||||
// setupPrefs is the page setup as edited (and saved as the default); activePrefs adds the imported fonts the template
|
||||
// draws, which is what renders and what is frozen on the invoice at issue. An issued invoice keeps its frozen fonts.
|
||||
const userFonts = useUserFonts();
|
||||
const setupPrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
|
||||
const activePrefs = useMemo(() => (issued ? setupPrefs : withUserFonts(setupPrefs, userFonts)), [issued, setupPrefs, userFonts]);
|
||||
|
||||
// The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped
|
||||
// while this view is hidden (it stays mounted) so typing elsewhere never starts a render.
|
||||
@@ -1038,7 +1044,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
<Column sm={4} md={8} lg={7}>
|
||||
<div style={{ position: "sticky", top: "3rem" }}>
|
||||
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
|
||||
<PageSetupControls prefs={activePrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
|
||||
<PageSetupControls prefs={setupPrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
|
||||
<Suspense fallback={<InlineLoading description="Loading preview…" />}>
|
||||
<PdfPreview
|
||||
bytes={pdf.bytes}
|
||||
|
||||
Reference in New Issue
Block a user