Skip to content

Commit dc8b803

Browse files
authored
Merge pull request #607 from embedpdf/feature/customize-fonts
Better way of dealing with fonts, allow custom fonts and custom fonts…
2 parents 43f5a2b + fbba983 commit dc8b803

11 files changed

Lines changed: 679 additions & 48 deletions

File tree

.changeset/snippet-fonts-config.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@embedpdf/snippet': patch
3+
---
4+
5+
Add `fonts` configuration to the snippet viewer for controlling external webfont loading. Both defaults remain unchanged (Open Sans for the UI chrome, Caveat / Dancing Script / Great Vibes / Pacifico for the Create Signature "Type" tab), but integrators can now opt out cleanly for GDPR-sensitive, airgapped, or self-hosted deployments.
6+
7+
- `fonts.ui`: controls the snippet UI font. `null` skips the `<link>` (falls back to the system font stack); an object with `family` and/or `stylesheetUrl` lets you change the viewer font family independently from the stylesheet source, with omitted `stylesheetUrl` treated as no managed `<link>`.
8+
- `fonts.signature`: controls the signature "Type" tab fonts. `null` skips the `<link>` and hides the "Type" tab; an object with `stylesheetUrl` and/or `fonts` lets you self-host the stylesheet and override the font list.
9+
10+
Both stylesheets are now registered at document scope with deduped `<link rel="stylesheet">` elements so `@font-face` works consistently across browsers and typed signatures can render correctly to canvas. Existing matching stylesheet links are reused when possible.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@embedpdf/snippet': patch
3+
---
4+
5+
Prevent the zoom percentage `%` symbol in the custom zoom toolbar from wrapping to a new line when the toolbar is resized to a narrow width. The input and `%` are now rendered as a single non-wrapping flex group that clips overflow instead of breaking the layout.

viewers/snippet/src/components/app.tsx

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { h, Fragment } from 'preact';
2-
import { useMemo } from 'preact/hooks';
2+
import type { JSX } from 'preact';
3+
import { useEffect, useMemo } from 'preact/hooks';
34
import styles from '../styles/index.css';
45
import { EmbedPDF } from '@embedpdf/core/preact';
56
import { createPluginRegistration, PluginRegistry, PermissionConfig } from '@embedpdf/core';
@@ -128,6 +129,7 @@ import { WidgetEditSidebar } from '@/components/widget-edit-sidebar';
128129
import { RubberStampSidebar } from '@/components/rubber-stamp-sidebar';
129130
import { SignatureSidebar } from '@/components/signature-sidebar';
130131
import { SignatureCreateModal } from '@/components/signature-create-modal';
132+
import { SnippetConfigProvider } from '@/components/snippet-config-context';
131133
import { SchemaSelectionMenu } from '@/ui/schema-selection-menu';
132134
import { SchemaOverlay } from '@/ui/schema-overlay';
133135
import { PrintModal } from '@/components/print-modal';
@@ -158,11 +160,66 @@ import { Capture } from '@/components/capture';
158160
import { ProtectModal } from './protect-modal';
159161
import { UnlockOwnerOverlay } from './unlock-owner-overlay';
160162
import { ViewPermissionsModal } from './view-permissions-modal';
163+
import { ensureFontStylesheet } from './font-loader';
164+
import { resolveUiFontConfig } from './font-config';
161165

162166
// ============================================================================
163167
// Main Configuration Interface - Uses actual plugin config types directly
164168
// ============================================================================
165169

170+
export interface SnippetFontStylesheetConfig {
171+
/**
172+
* Stylesheet URL to register at document scope.
173+
* Omit (or set to `null`) to skip the `<link>` entirely — useful when the
174+
* fonts are already loaded elsewhere or when you want to rely on the CSS
175+
* `font-family` fallback stack.
176+
*/
177+
stylesheetUrl?: string | null;
178+
}
179+
180+
export interface SnippetUiFontConfig extends SnippetFontStylesheetConfig {
181+
/**
182+
* CSS font-family used by the viewer UI.
183+
* Defaults to `'Open Sans', system-ui, sans-serif`.
184+
*/
185+
family?: string;
186+
}
187+
188+
/**
189+
* Fonts shown in the "Type" tab of the Create Signature modal.
190+
* UI-only concern; not part of the signature plugin config.
191+
*/
192+
export interface SnippetSignatureFontConfig extends SnippetFontStylesheetConfig {
193+
/**
194+
* Font list shown in the "Type" tab's font picker.
195+
* Defaults to Caveat / Dancing Script / Great Vibes / Pacifico.
196+
*/
197+
fonts?: Array<{ name: string; family: string }>;
198+
}
199+
200+
/**
201+
* Controls the snippet's external webfont loading. Every field is opt-out:
202+
* omit for the built-in Google Fonts defaults, or set to `null` to skip the
203+
* external request and fall through to the CSS font-family fallback stack.
204+
*/
205+
export interface SnippetFontsConfig {
206+
/**
207+
* Stylesheet URL for the snippet UI font (Open Sans by default).
208+
* - `undefined`: loads Open Sans from Google Fonts at init.
209+
* - `null`: skip the `<link>`; the UI falls back to the system font stack.
210+
* - object: override the UI font family and/or stylesheet URL.
211+
*/
212+
ui?: SnippetUiFontConfig | null;
213+
214+
/**
215+
* Fonts for the "Type" tab of the Create Signature modal.
216+
* - `undefined`: loads the 4 cursive families from Google Fonts on first open.
217+
* - `null`: skip the `<link>`; typed signatures use the OS cursive stack.
218+
* - object: self-host / override `stylesheetUrl` and/or `fonts`.
219+
*/
220+
signature?: SnippetSignatureFontConfig | null;
221+
}
222+
166223
export interface PDFViewerConfig {
167224
// === Document Source (optional) ===
168225
/** URL or path to the PDF document. If not provided, viewer loads with no document. */
@@ -296,6 +353,13 @@ export interface PDFViewerConfig {
296353
/** Signature options (mode, default size) */
297354
signature?: Partial<SignaturePluginConfig>;
298355

356+
// Fonts (snippet-level webfont loading)
357+
/**
358+
* Controls external webfonts loaded by the snippet UI. See `SnippetFontsConfig`.
359+
* Useful for GDPR-sensitive, airgapped, or self-hosted deployments.
360+
*/
361+
fonts?: SnippetFontsConfig;
362+
299363
// Infrastructure
300364
/** History/undo options */
301365
history?: Partial<HistoryPluginConfig>;
@@ -546,18 +610,26 @@ export function PDFViewer({ config, onRegistryReady }: PDFViewerProps) {
546610
[],
547611
);
548612

613+
const uiFont = resolveUiFontConfig(config.fonts?.ui);
614+
const uiFontStyle = useMemo(() => ({ '--ep-font-family': uiFont.family }), [uiFont.family]);
615+
616+
useEffect(() => {
617+
if (!uiFont.stylesheetUrl) return;
618+
ensureFontStylesheet('ui', uiFont.stylesheetUrl, [uiFont.family]);
619+
}, [uiFont.family, uiFont.stylesheetUrl]);
620+
549621
if (!engine || isLoading)
550622
return (
551-
<>
623+
<div className="embedpdf-snippet-root" style={uiFontStyle}>
552624
<style>{styles}</style>
553625
<div className="flex h-full w-full items-center justify-center">
554626
<LoadingIndicator size="lg" text="Initializing PDF engine..." />
555627
</div>
556-
</>
628+
</div>
557629
);
558630

559631
return (
560-
<>
632+
<div className="embedpdf-snippet-root" style={uiFontStyle}>
561633
<style>{styles}</style>
562634
<EmbedPDF
563635
config={{
@@ -675,16 +747,21 @@ export function PDFViewer({ config, onRegistryReady }: PDFViewerProps) {
675747
{pluginsReady ? (
676748
<>
677749
{activeDocumentId ? (
678-
<UIProvider
679-
documentId={activeDocumentId}
680-
components={uiComponents}
681-
renderers={uiRenderers}
682-
className="relative flex h-full w-full select-none flex-col"
683-
>
684-
<ViewerLayout documentId={activeDocumentId} tabBarVisibility={config.tabBar} />
685-
<Capture documentId={activeDocumentId} />
686-
<HintLayer documentId={activeDocumentId} />
687-
</UIProvider>
750+
<SnippetConfigProvider config={config}>
751+
<UIProvider
752+
documentId={activeDocumentId}
753+
components={uiComponents}
754+
renderers={uiRenderers}
755+
className="relative flex h-full w-full select-none flex-col"
756+
>
757+
<ViewerLayout
758+
documentId={activeDocumentId}
759+
tabBarVisibility={config.tabBar}
760+
/>
761+
<Capture documentId={activeDocumentId} />
762+
<HintLayer documentId={activeDocumentId} />
763+
</UIProvider>
764+
</SnippetConfigProvider>
688765
) : (
689766
<EmptyState />
690767
)}
@@ -697,6 +774,6 @@ export function PDFViewer({ config, onRegistryReady }: PDFViewerProps) {
697774
</>
698775
)}
699776
</EmbedPDF>
700-
</>
777+
</div>
701778
);
702779
}

viewers/snippet/src/components/custom-zoom-toolbar.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,23 @@ export function CustomZoomToolbar({ documentId }: CustomZoomToolbarProps) {
6666
<div className="relative">
6767
<div className="bg-interactive-hover flex items-center rounded">
6868
{/* Editable Zoom Percentage Input */}
69-
<form onSubmit={handleZoomChange} className="block">
69+
<form
70+
onSubmit={handleZoomChange}
71+
className="flex min-w-0 flex-nowrap items-center overflow-hidden whitespace-nowrap"
72+
>
7073
<input
7174
name="zoom"
7275
type="text"
7376
inputMode="numeric"
7477
pattern="\d*"
75-
className="h-6 w-8 border-0 bg-transparent p-0 text-right text-sm outline-none focus:outline-none"
78+
className="h-6 w-8 min-w-0 shrink border-0 bg-transparent p-0 text-right text-sm outline-none focus:outline-none"
7679
aria-label="Set zoom"
7780
autoFocus={false}
7881
value={inputValue}
7982
onInput={handleInputChange}
8083
onBlur={handleBlur}
8184
/>
82-
<span className="text-sm">%</span>
85+
<span className="shrink-0 text-sm">%</span>
8386
</form>
8487
<CommandButton
8588
commandId="zoom:toggle-menu"
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { SnippetSignatureFontConfig, SnippetUiFontConfig } from './app';
2+
3+
/**
4+
* Default CSS font-family stack for the snippet UI.
5+
* `system-ui, sans-serif` is the fallback when Open Sans isn't loaded.
6+
*/
7+
export const DEFAULT_UI_FONT_FAMILY = "'Open Sans', system-ui, sans-serif";
8+
9+
/**
10+
* Default Google Fonts stylesheet URL for the snippet UI font (Open Sans).
11+
*/
12+
export const DEFAULT_UI_FONT_URL =
13+
'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&display=swap';
14+
15+
/**
16+
* Default Google Fonts stylesheet URL for the signature "Type" tab.
17+
*/
18+
export const DEFAULT_SIGNATURE_FONTS_URL =
19+
'https://fonts.googleapis.com/css2?family=Caveat&family=Dancing+Script&family=Great+Vibes&family=Pacifico&display=swap';
20+
21+
/**
22+
* Default font list for the signature "Type" tab.
23+
*/
24+
export const DEFAULT_SIGNATURE_FONTS: ReadonlyArray<{ name: string; family: string }> = [
25+
{ name: 'Dancing Script', family: "'Dancing Script', cursive" },
26+
{ name: 'Great Vibes', family: "'Great Vibes', cursive" },
27+
{ name: 'Pacifico', family: "'Pacifico', cursive" },
28+
{ name: 'Caveat', family: "'Caveat', cursive" },
29+
];
30+
31+
export interface ResolvedUiFontConfig {
32+
family: string;
33+
stylesheetUrl: string | null;
34+
}
35+
36+
export interface ResolvedSignatureFontsConfig {
37+
fonts: ReadonlyArray<{ name: string; family: string }>;
38+
stylesheetUrl: string | null;
39+
/**
40+
* Whether the signature "Type" tab should be shown. False when the snippet
41+
* manages no stylesheet AND the integrator has not provided a fonts list.
42+
*/
43+
enabled: boolean;
44+
}
45+
46+
/**
47+
* Resolves the UI font config from `config.fonts?.ui`.
48+
*
49+
* - `undefined`: full default — Open Sans family + Google Fonts URL.
50+
* - `null`: full opt-out — default family stack, no `<link>`.
51+
* - object: caller takes over. Each field is independent: `family` falls
52+
* back to the default stack when omitted, and `stylesheetUrl` falls back
53+
* to `null` (no managed `<link>`) when omitted. There is no implicit
54+
* default-URL fallback when only `family` is specified, since loading
55+
* Open Sans while rendering a different family is almost never intended.
56+
*/
57+
export function resolveUiFontConfig(
58+
value: SnippetUiFontConfig | null | undefined,
59+
): ResolvedUiFontConfig {
60+
if (value === null) {
61+
return { family: DEFAULT_UI_FONT_FAMILY, stylesheetUrl: null };
62+
}
63+
if (value === undefined) {
64+
return { family: DEFAULT_UI_FONT_FAMILY, stylesheetUrl: DEFAULT_UI_FONT_URL };
65+
}
66+
return {
67+
family: value.family ?? DEFAULT_UI_FONT_FAMILY,
68+
stylesheetUrl: value.stylesheetUrl ?? null,
69+
};
70+
}
71+
72+
/**
73+
* Resolves the signature "Type" tab font config from `config.fonts?.signature`.
74+
*
75+
* - `undefined`: full default — built-in font list + Google Fonts URL.
76+
* - `null`: full opt-out — no `<link>` and no "Type" tab.
77+
* - object: caller takes over. `fonts` falls back to the built-in list when
78+
* omitted or empty; `stylesheetUrl` falls back to `null` when omitted.
79+
*/
80+
export function resolveSignatureFontsConfig(
81+
value: SnippetSignatureFontConfig | null | undefined,
82+
): ResolvedSignatureFontsConfig {
83+
if (value === null) {
84+
return { fonts: DEFAULT_SIGNATURE_FONTS, stylesheetUrl: null, enabled: false };
85+
}
86+
if (value === undefined) {
87+
return {
88+
fonts: DEFAULT_SIGNATURE_FONTS,
89+
stylesheetUrl: DEFAULT_SIGNATURE_FONTS_URL,
90+
enabled: true,
91+
};
92+
}
93+
94+
if (value.fonts && value.fonts.length > 0) {
95+
return {
96+
fonts: value.fonts,
97+
stylesheetUrl: value.stylesheetUrl ?? null,
98+
enabled: true,
99+
};
100+
}
101+
102+
return {
103+
fonts: DEFAULT_SIGNATURE_FONTS,
104+
stylesheetUrl: value.stylesheetUrl ?? null,
105+
enabled: value.stylesheetUrl != null,
106+
};
107+
}

0 commit comments

Comments
 (0)