Files
pdfme/packages/schemas/__tests__/text.test.ts

1191 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, vi } from 'vitest';
import type { Font as FontKitFont } from 'fontkit';
import { Font, getDefaultFont, mm2pt } from '@pdfme/common';
import type { BasePdf, PropPanelSchema, PropPanelWidgetProps } from '@pdfme/common';
import {
calculateDynamicFontSize,
getBrowserVerticalFontAdjustments,
getFontDescentInPt,
getFontKitFont,
getSplittedLines,
filterStartJP,
filterEndJP,
widthOfTextAtSize,
} from '../src/text/helper.js';
import {
escapeInlineMarkdown,
parseInlineMarkdown,
stripInlineMarkdown,
} from '../src/text/inlineMarkdown.js';
import {
calculateDynamicRichTextFontSize,
getRichTextLineText,
isInlineMarkdownTextSchema,
layoutRichTextLines,
resolveFontVariant,
type ResolvedRichTextRun,
} from '../src/text/richText.js';
import {
LINE_START_FORBIDDEN_CHARS,
LINE_END_FORBIDDEN_CHARS,
TEXT_OVERFLOW_EXPAND,
TEXT_OVERFLOW_VISIBLE,
} from '../src/text/constants.js';
import { getDynamicLayoutForText } from '../src/text/dynamicTemplate.js';
import { mergeTextLineRangeValue } from '../src/text/measure.js';
import { shouldUseDynamicFontSize } from '../src/text/overflow.js';
import { propPanel as textPropPanel } from '../src/text/propPanel.js';
import { getDynamicLayoutForMultiVariableText } from '../src/multiVariableText/dynamicTemplate.js';
import { FontWidthCalcValues, TextSchema } from '../src/text/types.js';
import type { MultiVariableTextSchema } from '../src/multiVariableText/types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sansData = readFileSync(path.join(__dirname, `/assets/fonts/SauceHanSansJP.ttf`));
const serifData = readFileSync(path.join(__dirname, `/assets/fonts/SauceHanSerifJP.ttf`));
afterEach(() => {
vi.unstubAllGlobals();
});
const createMockFont = (
advanceWidth: number,
hasGlyphForCodePoint = (_codePoint: number) => true,
) =>
({
unitsPerEm: 1000,
ascent: 800,
descent: -200,
bbox: { maxY: 800, minY: -200 },
layout: (text: string) => ({
glyphs: Array.from(text, () => ({ advanceWidth })),
}),
hasGlyphForCodePoint,
}) as unknown as FontKitFont;
const getSampleFont = (): Font => ({
SauceHanSansJP: { fallback: true, data: sansData },
SauceHanSerifJP: { data: serifData },
});
const getTextSchema = () => {
const textSchema: TextSchema = {
name: 'test',
type: 'text',
content: 'test',
position: { x: 0, y: 0 },
width: 50,
height: 20,
alignment: 'left',
verticalAlignment: 'top',
fontColor: '#000000',
backgroundColor: '#ffffff',
lineHeight: 1,
characterSpacing: 1,
fontSize: 14,
};
return textSchema;
};
const getTextPropPanelSchema = ({
basePdf,
activeSchema,
}: {
basePdf?: BasePdf;
activeSchema?: Partial<TextSchema>;
} = {}) => {
if (typeof textPropPanel.schema !== 'function') {
throw new Error('Text propPanel schema should be a function.');
}
return textPropPanel.schema({
activeSchema: {
...getTextSchema(),
id: 'text-id',
...activeSchema,
},
activeElements: [],
changeSchemas: () => undefined,
schemas: [],
basePdf,
options: { font: getSampleFont() },
theme: {},
i18n: (key: string) => key,
} as unknown as Omit<PropPanelWidgetProps, 'rootElement'>);
};
const getOverflowOptionValues = (schema: Record<string, PropPanelSchema>) => {
const overflow = schema.overflow as PropPanelSchema & {
props: { options: Array<{ value: string }> };
};
return overflow.props.options.map((option) => option.value);
};
describe('parseInlineMarkdown', () => {
it('parses supported inline markdown styles', () => {
const result = parseInlineMarkdown(
'Hello **bold** and *italic* and ***both*** and ~~gone~~ and `code`',
);
expect(result).toEqual([
{ text: 'Hello ' },
{ text: 'bold', bold: true },
{ text: ' and ' },
{ text: 'italic', italic: true },
{ text: ' and ' },
{ text: 'both', bold: true, italic: true },
{ text: ' and ' },
{ text: 'gone', strikethrough: true },
{ text: ' and ' },
{ text: 'code', code: true },
]);
});
it('keeps escaped and unmatched delimiters as text', () => {
expect(parseInlineMarkdown('\\*\\*literal\\*\\* and **unclosed')).toEqual([
{ text: '**literal** and **unclosed' },
]);
});
it('strips inline markdown markers', () => {
expect(stripInlineMarkdown('Hello **bold** and `code`')).toBe('Hello bold and code');
});
it('parses links as href runs while preserving nested style', () => {
expect(parseInlineMarkdown('See [**pdfme** docs](https://pdfme.com/docs).')).toEqual([
{ text: 'See ' },
{ text: 'pdfme', bold: true, href: 'https://pdfme.com/docs' },
{ text: ' docs', href: 'https://pdfme.com/docs' },
{ text: '.' },
]);
expect(stripInlineMarkdown('See [pdfme](https://pdfme.com).')).toBe('See pdfme.');
});
it('only parses safe link destinations', () => {
expect(
parseInlineMarkdown('[web](https://pdfme.com) [mail](mailto:hello@pdfme.com) [toc](#toc)'),
).toEqual([
{ text: 'web', href: 'https://pdfme.com' },
{ text: ' ' },
{ text: 'mail', href: 'mailto:hello@pdfme.com' },
{ text: ' ' },
{ text: 'toc', href: '#toc' },
]);
expect(parseInlineMarkdown('[bad](javascript:alert(1)) [file](file:///tmp/a)')).toEqual([
{ text: '[bad](javascript:alert(1)) [file](file:///tmp/a)' },
]);
});
it('handles empty link labels and destinations explicitly', () => {
expect(parseInlineMarkdown('Go [](https://pdfme.com).')).toEqual([{ text: 'Go .' }]);
expect(parseInlineMarkdown('Go [empty]().')).toEqual([{ text: 'Go [empty]().' }]);
});
it('keeps escaped links as literal text', () => {
const escaped = escapeInlineMarkdown('[pdfme](https://pdfme.com)');
expect(escaped).toBe('\\[pdfme\\]\\(https://pdfme.com\\)');
expect(parseInlineMarkdown(escaped)).toEqual([{ text: '[pdfme](https://pdfme.com)' }]);
});
it('escapes markdown markers for literal content', () => {
const escaped = escapeInlineMarkdown('**literal** and `code` with \\');
expect(escaped).toBe('\\*\\*literal\\*\\* and \\`code\\` with \\\\');
expect(parseInlineMarkdown(`**${escaped}**`)).toEqual([
{ text: '**literal** and `code` with \\', bold: true },
]);
});
});
describe('resolveFontVariant', () => {
const font = {
Base: { data: new Uint8Array(), fallback: true },
Bold: { data: new Uint8Array() },
Italic: { data: new Uint8Array() },
BoldItalic: { data: new Uint8Array() },
Mono: { data: new Uint8Array() },
} as unknown as Font;
it('uses loaded variant fonts when they are available', () => {
const schema = {
...getTextSchema(),
fontName: 'Base',
fontVariants: { bold: 'Bold', italic: 'Italic', boldItalic: 'BoldItalic', code: 'Mono' },
};
expect(resolveFontVariant({ text: 'x', bold: true }, schema, font)).toEqual({
fontName: 'Bold',
syntheticBold: false,
syntheticItalic: false,
});
expect(resolveFontVariant({ text: 'x', bold: true, italic: true }, schema, font)).toEqual({
fontName: 'BoldItalic',
syntheticBold: false,
syntheticItalic: false,
});
expect(resolveFontVariant({ text: 'x', code: true }, schema, font)).toEqual({
fontName: 'Mono',
syntheticBold: false,
syntheticItalic: false,
});
});
it('falls back to synthetic styling when variant fonts are missing', () => {
const schema = {
...getTextSchema(),
fontName: 'Base',
fontVariants: { bold: 'MissingBold', italic: 'Italic' },
};
expect(resolveFontVariant({ text: 'x', bold: true }, schema, font)).toEqual({
fontName: 'Base',
syntheticBold: true,
syntheticItalic: false,
});
expect(resolveFontVariant({ text: 'x', bold: true, italic: true }, schema, font)).toEqual({
fontName: 'Italic',
syntheticBold: true,
syntheticItalic: false,
});
});
it('can disable synthetic fallback or throw on missing variants', () => {
const plainSchema = { ...getTextSchema(), fontName: 'Base', fontVariantFallback: 'plain' };
expect(resolveFontVariant({ text: 'x', bold: true }, plainSchema, font)).toEqual({
fontName: 'Base',
syntheticBold: false,
syntheticItalic: false,
});
const errorSchema = { ...getTextSchema(), fontName: 'Base', fontVariantFallback: 'error' };
expect(() => resolveFontVariant({ text: 'x', italic: true }, errorSchema, font)).toThrow(
'Missing font variant',
);
});
});
describe('isInlineMarkdownTextSchema', () => {
it('enables inline markdown for read-only text schemas only', () => {
expect(
isInlineMarkdownTextSchema({
...getTextSchema(),
textFormat: 'inline-markdown',
readOnly: true,
}),
).toBe(true);
expect(
isInlineMarkdownTextSchema({
...getTextSchema(),
textFormat: 'inline-markdown',
readOnly: false,
}),
).toBe(false);
});
it('keeps inline markdown available for multiVariableText schemas', () => {
expect(
isInlineMarkdownTextSchema({
...getTextSchema(),
type: 'multiVariableText',
textFormat: 'inline-markdown',
readOnly: false,
}),
).toBe(true);
});
});
describe('text prop panel', () => {
it('offers overflow expand for blank basePdf', () => {
const schema = getTextPropPanelSchema({
basePdf: { width: 210, height: 297, padding: [10, 10, 10, 10] },
});
expect(getOverflowOptionValues(schema)).toEqual([
TEXT_OVERFLOW_VISIBLE,
TEXT_OVERFLOW_EXPAND,
]);
});
it('hides overflow expand for custom basePdf', () => {
const schema = getTextPropPanelSchema({
basePdf: 'data:application/pdf;base64,AA==' as BasePdf,
});
expect(getOverflowOptionValues(schema)).toEqual([TEXT_OVERFLOW_VISIBLE]);
});
it('keeps overflow expand available for text-derived schemas that do not use text expand', () => {
const schema = getTextPropPanelSchema({
basePdf: 'data:application/pdf;base64,AA==' as BasePdf,
activeSchema: { type: 'select' },
});
expect(getOverflowOptionValues(schema)).toEqual([
TEXT_OVERFLOW_VISIBLE,
TEXT_OVERFLOW_EXPAND,
]);
});
it('keeps dynamic font size controls enabled when custom basePdf has stale overflow expand', () => {
const schema = getTextPropPanelSchema({
basePdf: 'data:application/pdf;base64,AA==' as BasePdf,
activeSchema: {
overflow: TEXT_OVERFLOW_EXPAND,
dynamicFontSize: { min: 20, max: 30, fit: 'vertical' },
},
});
const dynamicFontSize = schema.dynamicFontSize as PropPanelSchema;
const minFontSize = dynamicFontSize.properties?.min;
expect(minFontSize?.hidden).toBe(false);
});
});
describe('text dynamic layout', () => {
const baseArgs = {
basePdf: { width: 100, height: 100, padding: [10, 10, 10, 10] },
options: { font: getSampleFont() },
_cache: new Map<string | number, unknown>(),
};
it('keeps the schema height when text overflow is undefined', async () => {
const schema = {
...getTextSchema(),
height: 5,
width: 20,
} as TextSchema;
const result = await getDynamicLayoutForText('long text '.repeat(20), {
...baseArgs,
schema,
});
expect(result.heights).toEqual([5]);
});
it('keeps the schema height when text overflow is visible', async () => {
const schema = {
...getTextSchema(),
height: 5,
width: 20,
overflow: 'visible',
} as TextSchema;
const result = await getDynamicLayoutForText('long text '.repeat(20), {
...baseArgs,
schema,
});
expect(result.heights).toEqual([5]);
});
it('expands text height when text overflow is expand', async () => {
const schema = {
...getTextSchema(),
height: 5,
width: 20,
overflow: 'expand',
} as TextSchema;
const result = await getDynamicLayoutForText('long text '.repeat(20), {
...baseArgs,
schema,
});
expect(result.heights.reduce((sum, height) => sum + height, 0)).toBeGreaterThan(5);
expect(result.heights.length).toBeGreaterThan(1);
});
it('does not shrink text height when the measured height is smaller', async () => {
const schema = {
...getTextSchema(),
height: 40,
width: 60,
overflow: 'expand',
} as TextSchema;
const result = await getDynamicLayoutForText('short', {
...baseArgs,
schema,
});
expect(result.heights).toEqual([40]);
});
it('lets overflow expand take priority over dynamic font size', async () => {
const schema = {
...getTextSchema(),
height: 5,
width: 20,
overflow: 'expand',
dynamicFontSize: { min: 4, max: 20, fit: 'vertical' },
} as TextSchema;
const result = await getDynamicLayoutForText('long text '.repeat(20), {
...baseArgs,
schema,
});
expect(result.heights.reduce((sum, height) => sum + height, 0)).toBeGreaterThan(5);
expect(
result.patchSplitSchema?.({
schema,
start: 0,
end: 1,
isSplit: false,
chunkHeight: result.heights[0],
}),
).toEqual({
dynamicFontSize: undefined,
__splitRange: { unit: 'textLine', start: 0, end: 1 },
__isSplit: false,
});
});
it('patches expanded text chunks with line ranges', async () => {
const schema = {
...getTextSchema(),
height: 5,
width: 20,
overflow: 'expand',
} as TextSchema;
const result = await getDynamicLayoutForText('long text '.repeat(20), {
...baseArgs,
schema,
});
expect(result.heights.length).toBeGreaterThan(2);
expect(
result.patchSplitSchema?.({
schema,
start: 1,
end: 3,
isSplit: true,
chunkHeight: result.heights[1] + result.heights[2],
}),
).toEqual({
dynamicFontSize: undefined,
__splitRange: { unit: 'textLine', start: 1, end: 3 },
__isSplit: true,
});
});
it('keeps text box padding and borders on the visible edges of split chunks', async () => {
const schema = {
...getTextSchema(),
height: 5,
width: 20,
overflow: 'expand',
borderColor: '#d0d7de',
borderWidth: { top: 0.5, right: 0.2, bottom: 0.5, left: 0.2 },
padding: { top: 2, right: 3, bottom: 2, left: 3 },
} as TextSchema;
const result = await getDynamicLayoutForText('long text '.repeat(20), {
...baseArgs,
schema,
});
expect(result.heights.length).toBeGreaterThan(2);
expect(
result.patchSplitSchema?.({
schema,
start: 0,
end: 1,
isSplit: true,
chunkHeight: result.heights[0],
}),
).toMatchObject({
borderWidth: { top: 0.5, right: 0.2, bottom: 0, left: 0.2 },
padding: { top: 2, right: 3, bottom: 0, left: 3 },
});
expect(
result.patchSplitSchema?.({
schema,
start: result.heights.length - 1,
end: result.heights.length,
isSplit: true,
chunkHeight: result.heights[result.heights.length - 1],
}),
).toMatchObject({
borderWidth: { top: 0, right: 0.2, bottom: 0.5, left: 0.2 },
padding: { top: 0, right: 3, bottom: 2, left: 3 },
});
});
it('merges form edits from a split text line range into the full value', async () => {
const schema = {
...getTextSchema(),
width: 20,
overflow: 'expand',
__splitRange: { unit: 'textLine', start: 1, end: 3 },
} as TextSchema;
const result = await mergeTextLineRangeValue({
value: 'long text '.repeat(8),
replacement: 'edited one\r\nedited two\redited three\nedited four',
schema,
font: getSampleFont(),
_cache: new Map<string | number, unknown>(),
});
expect(result).toContain('edited one\nedited two\nedited three\nedited four');
expect(result).not.toBe('edited one\nedited two\nedited three\nedited four');
});
it('does not use dynamic font size while text overflow is expand', () => {
expect(
shouldUseDynamicFontSize({
dynamicFontSize: { min: 20, max: 30, fit: 'vertical' },
overflow: 'expand',
}),
).toBe(false);
});
it('treats overflow expand as visible for custom basePdf dynamic font sizing', () => {
expect(
shouldUseDynamicFontSize(
{
type: 'text',
dynamicFontSize: { min: 20, max: 30, fit: 'vertical' },
overflow: 'expand',
},
'data:application/pdf;base64,AA==' as BasePdf,
),
).toBe(true);
});
it('expands multiVariableText after substituting variables', async () => {
const schema = {
...getTextSchema(),
name: 'message',
type: 'multiVariableText',
height: 5,
width: 20,
overflow: 'expand',
text: 'Hello {name}',
variables: ['name'],
required: true,
} as MultiVariableTextSchema;
const result = await getDynamicLayoutForMultiVariableText(
JSON.stringify({ name: 'long text '.repeat(20) }),
{
...baseArgs,
schema,
},
);
expect(result.heights[0]).toBeGreaterThan(5);
});
it('expands multiVariableText when optional variables are empty strings', async () => {
const schema = {
...getTextSchema(),
name: 'message',
type: 'multiVariableText',
height: 5,
width: 20,
overflow: 'expand',
text: 'Title\n{name}\nFooter\nDone',
variables: ['name'],
required: false,
} as MultiVariableTextSchema;
const result = await getDynamicLayoutForMultiVariableText(JSON.stringify({ name: '' }), {
...baseArgs,
schema,
});
expect(result.heights[0]).toBeGreaterThan(5);
});
it('expands read-only multiVariableText as resolved text', async () => {
const schema = {
...getTextSchema(),
name: 'message',
type: 'multiVariableText',
readOnly: true,
height: 5,
width: 20,
overflow: 'expand',
text: 'Hello {name}',
variables: ['name'],
required: true,
} as MultiVariableTextSchema;
const result = await getDynamicLayoutForMultiVariableText('Hello long text '.repeat(20), {
...baseArgs,
schema,
});
expect(result.heights[0]).toBeGreaterThan(5);
});
});
describe('layoutRichTextLines', () => {
const fontKitFont = createMockFont(1000 / 12);
const createRun = (
text: string,
options: Partial<ResolvedRichTextRun> = {},
): ResolvedRichTextRun => ({
text,
fontName: 'Base',
fontKitFont,
syntheticBold: false,
syntheticItalic: false,
...options,
});
it('keeps a word together when style changes inside the word', () => {
const lines = layoutRichTextLines({
runs: [createRun('x he'), createRun('llo', { bold: true })],
fontSize: 12,
characterSpacing: 0,
boxWidthInPt: 6,
});
expect(lines.map(getRichTextLineText)).toEqual(['x ', 'hello']);
expect(lines[1].runs.map((run) => run.text)).toEqual(['he', 'llo']);
});
it('wraps before splitting an oversized token at the end of a line', () => {
const lines = layoutRichTextLines({
runs: [createRun('abc '), createRun('123456', { code: true })],
fontSize: 12,
characterSpacing: 0,
boxWidthInPt: 7,
});
expect(lines.map(getRichTextLineText)).toEqual(['abc ', '1234', '56']);
});
});
describe('calculateDynamicRichTextFontSize', () => {
it('measures inline markdown text with resolved variant fonts', async () => {
const baseFont = createMockFont(500);
const boldFont = createMockFont(1000);
const font = {
Base: { data: new Uint8Array(), fallback: true },
Bold: { data: new Uint8Array() },
} as Font;
const cache = new Map<string | number, FontKitFont>([
['getFontKitFont-Base', baseFont],
['getFontKitFont-Bold', boldFont],
]);
const schema: TextSchema = {
...getTextSchema(),
fontName: 'Base',
width: 10,
height: 20,
characterSpacing: 0,
fontSize: 20,
textFormat: 'inline-markdown',
readOnly: true,
fontVariants: { bold: 'Bold' },
dynamicFontSize: { min: 4, max: 20, fit: 'horizontal' },
};
const fontSize = await calculateDynamicRichTextFontSize({
value: '**abcdef**',
schema,
font,
_cache: cache,
});
expect(widthOfTextAtSize('abcdef', boldFont, fontSize, 0)).toBeLessThanOrEqual(
mm2pt(schema.width),
);
expect(fontSize).toBeLessThan(
calculateDynamicFontSize({
textSchema: schema,
fontKitFont: baseFont,
value: 'abcdef',
}),
);
});
});
describe('getSplitPosition test with mocked font width calculations', () => {
/**
* To simplify these tests we use a mocked font where each glyph has width 1.
* Therefore, setting the boxWidthInPt to 5 should result in a split after 5 characters.
*/
const mockedAdvanceWidth = 1000 / 12;
const mockedFont = {
unitsPerEm: 1000,
layout: (text: string) => ({
glyphs: Array.from(text, () => ({ advanceWidth: mockedAdvanceWidth })),
}),
} as unknown as FontKitFont;
const mockCalcValues: FontWidthCalcValues = {
font: mockedFont,
fontSize: 12,
characterSpacing: 0,
boxWidthInPt: 5,
};
it('does not split an empty string', () => {
expect(getSplittedLines('', mockCalcValues)).toEqual(['']);
});
it('does not split a short line', () => {
expect(getSplittedLines('a', mockCalcValues)).toEqual(['a']);
expect(getSplittedLines('aaaa', mockCalcValues)).toEqual(['aaaa']);
});
it('splits a line to the nearest previous breakable char', () => {
expect(getSplittedLines('aaa bbb', mockCalcValues)).toEqual(['aaa', 'bbb']);
expect(getSplittedLines('top-hat', mockCalcValues)).toEqual(['top-', 'hat']);
expect(getSplittedLines('top—hat', mockCalcValues)).toEqual(['top—', 'hat']); // em dash
expect(getSplittedLines('tophat', mockCalcValues)).toEqual(['top', 'hat']); // en dash
});
it('splits a line where the split point is on a breakable char', () => {
expect(getSplittedLines('aaaaa bbbbb', mockCalcValues)).toEqual(['aaaaa', 'bbbbb']);
expect(getSplittedLines('left-hand', mockCalcValues)).toEqual(['left-', 'hand']);
});
it('splits a long line in the middle of a word if too long', () => {
expect(getSplittedLines('aaaaaa bbb', mockCalcValues)).toEqual(['aaaaa', 'a bbb']);
expect(getSplittedLines('aaaaaa-a b', mockCalcValues)).toEqual(['aaaaa', 'a-a b']);
expect(getSplittedLines('aaaaa-aa b', mockCalcValues)).toEqual(['aaaaa', '-aa b']);
});
it('splits a long line without breakable chars at exactly 5 chars', () => {
expect(getSplittedLines('abcdef', mockCalcValues)).toEqual(['abcde', 'f']);
});
it('splits a very long line without breakable chars at exactly 5 chars', () => {
expect(getSplittedLines('abcdefghijklmn', mockCalcValues)).toEqual(['abcde', 'fghij', 'klmn']);
});
it('splits a line with lots of words', () => {
expect(getSplittedLines('a b c d e', mockCalcValues)).toEqual(['a b c', 'd e']);
});
});
describe('getSplittedLines test with real font width calculations', () => {
const font = getDefaultFont();
const baseCalcValues = {
fontSize: 12,
characterSpacing: 1,
boxWidthInPt: 40,
};
it('should not split a line when the text is shorter than the width', async () => {
const _cache = new Map();
await getFontKitFont(getTextSchema().fontName, font, _cache).then((fontKitFont) => {
const fontWidthCalcs = Object.assign({}, baseCalcValues, { font: fontKitFont });
const result = getSplittedLines('short', fontWidthCalcs);
expect(result).toEqual(['short']);
});
});
it('should split a line when the text is longer than the width', async () => {
const _cache = new Map();
await getFontKitFont(getTextSchema().fontName, font, _cache).then((fontKitFont) => {
const fontWidthCalcs = Object.assign({}, baseCalcValues, { font: fontKitFont });
const result = getSplittedLines('this will wrap', fontWidthCalcs);
expect(result).toEqual(['this', 'will', 'wrap']);
});
});
it('should split a line in the middle when unspaced text will not fit on a line', async () => {
const _cache = new Map();
await getFontKitFont(getTextSchema().fontName, font, _cache).then((fontKitFont) => {
const fontWidthCalcs = Object.assign({}, baseCalcValues, { font: fontKitFont });
const result = getSplittedLines('thiswillbecut', fontWidthCalcs);
expect(result).toEqual(['thiswi', 'llbecu', 't']);
});
});
it('should not split text when it is impossible due to size constraints', async () => {
const _cache = new Map();
await getFontKitFont(getTextSchema().fontName, font, _cache).then((fontKitFont) => {
const fontWidthCalcs = Object.assign({}, baseCalcValues, { font: fontKitFont });
fontWidthCalcs.boxWidthInPt = 2;
const result = getSplittedLines('thiswillnotbecut', fontWidthCalcs);
expect(result).toEqual(['thiswillnotbecut']);
});
});
});
describe('getFontKitFont remote font cache', () => {
const font: Font = {
RemoteSans: {
fallback: true,
data: 'https://example.com/fonts/remote-sans.ttf',
},
};
const createFontResponse = (init?: ResponseInit) => new Response(new Uint8Array(sansData), init);
it('shares an in-flight remote font load across concurrent calls', async () => {
const fetchMock = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
return createFontResponse();
});
vi.stubGlobal('fetch', fetchMock);
const _cache = new Map();
const fontKitFonts = await Promise.all(
Array.from({ length: 20 }, () => getFontKitFont('RemoteSans', font, _cache)),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(new Set(fontKitFonts).size).toBe(1);
const cachedFont = await getFontKitFont('RemoteSans', font, _cache);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(cachedFont).toBe(fontKitFonts[0]);
});
it('clears a failed in-flight remote font load so it can be retried', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(createFontResponse({ status: 500 }))
.mockResolvedValueOnce(createFontResponse());
vi.stubGlobal('fetch', fetchMock);
const _cache = new Map();
await expect(
Promise.all([
getFontKitFont('RemoteSans', font, _cache),
getFontKitFont('RemoteSans', font, _cache),
]),
).rejects.toThrow('HTTP 500');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(_cache.size).toBe(0);
await expect(getFontKitFont('RemoteSans', font, _cache)).resolves.toBeTruthy();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(_cache.size).toBe(1);
});
});
describe('calculateDynamicFontSize with Default font', () => {
let fontKitFont: FontKitFont;
beforeAll(async () => {
fontKitFont = await getFontKitFont('SauceHanSansJP', getDefaultFont(), new Map());
});
it('should return default font size when dynamicFontSizeSetting is not provided', async () => {
const textSchema = getTextSchema();
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value: 'test' });
expect(result).toBe(14);
});
it('should return default font size when dynamicFontSizeSetting max is less than min', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 11, max: 10, fit: 'vertical' };
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value: 'test' });
expect(result).toBe(14);
});
it('should calculate a dynamic font size of vertical fit between min and max', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'test with a length string\n and a new line';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(19.25);
});
it('should calculate a dynamic font size of horizontal fit between min and max', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'horizontal' };
const value = 'test with a length string\n and a new line';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(11.25);
});
it('should calculate a dynamic font size between min and max regardless of current font size', async () => {
const textSchema = getTextSchema();
textSchema.fontSize = 2;
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'test with a length string\n and a new line';
let result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(19.25);
textSchema.fontSize = 40;
result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(19.25);
});
it('should return min font size when content is too big to fit given constraints', async () => {
const textSchema = getTextSchema();
textSchema.width = 10;
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'test with a length string\n and a new line';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(10);
});
it('should return max font size when content is too small to fit given constraints', async () => {
const textSchema = getTextSchema();
textSchema.width = 1000;
textSchema.height = 200;
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'test with a length string\n and a new line';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(30);
});
it('should not reduce font size below 0', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: -5, max: 10, fit: 'vertical' };
textSchema.width = 4;
textSchema.height = 1;
const value = 'a very \nlong \nmulti-line \nstring\nto';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBeGreaterThan(0);
});
it('should calculate a dynamic font size when a starting font size is passed that is lower than the eventual', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'test with a length string\n and a new line';
const startingFontSize = 18;
const result = calculateDynamicFontSize({textSchema, fontKitFont, value, startingFontSize});
expect(result).toBe(19.25);
});
it('should calculate a dynamic font size when a starting font size is passed that is higher than the eventual', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'horizontal' };
const value = 'test with a length string\n and a new line';
const startingFontSize = 36;
const result = calculateDynamicFontSize({textSchema, fontKitFont, value, startingFontSize});
expect(result).toBe(11.25);
});
it('should calculate a dynamic font size using vertical fit as a default if no fit provided', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'test with a length string\n and a new line';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(19.25);
});
});
describe('calculateDynamicFontSize with Custom font', () => {
let fontKitFont: FontKitFont;
beforeAll(async () => {
fontKitFont = await getFontKitFont('SauceHanSansJP', getSampleFont(), new Map());
});
it('should return smaller font size when dynamicFontSizeSetting is provided with horizontal fit', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'horizontal' };
const value = 'あいうあいうあい';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(16.75);
});
it('should return smaller font size when dynamicFontSizeSetting is provided with vertical fit', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'あいうあいうあい';
const result = calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(26);
});
it('should return min font size when content is too big to fit given constraints', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 20, max: 30, fit: 'vertical' };
const value = 'あいうあいうあいうあいうあいうあいうあいうあいうあいう';
const result = await calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(20);
});
it('should return max font size when content is too small to fit given constraints', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 10, max: 30, fit: 'vertical' };
const value = 'あ';
const result = await calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(30);
});
it('should return min font size when content is multi-line with too many lines for the container', async () => {
const textSchema = getTextSchema();
textSchema.dynamicFontSize = { min: 5, max: 20, fit: 'vertical' };
const value = 'あ\nいう\nあ\nいう\nあ\nいう\nあ\nいう\nあ\nいう\nあ\nいう';
const result = await calculateDynamicFontSize({ textSchema, fontKitFont, value });
expect(result).toBe(5);
});
});
describe('getFontDescentInPt test', () => {
test('it gets a descent size relative to the font size', () => {
expect(getFontDescentInPt({ descent: -400, unitsPerEm: 1000 } as FontKitFont, 12)).toBe(
-4.800000000000001
);
expect(getFontDescentInPt({ descent: 54, unitsPerEm: 1000 } as FontKitFont, 20)).toBe(1.08);
expect(getFontDescentInPt({ descent: -512, unitsPerEm: 2048 } as FontKitFont, 54)).toBe(-13.5);
});
});
describe('getBrowserVerticalFontAdjustments test', () => {
// Font with a base line-height of 1.349
const font = { ascent: 1037, descent: -312, unitsPerEm: 1000 } as FontKitFont;
test('it gets a top adjustment when vertically aligning top', () => {
expect(getBrowserVerticalFontAdjustments(font, 12, 1.0, 'top')).toEqual({
topAdj: 2.791301999999999,
bottomAdj: 0,
});
expect(getBrowserVerticalFontAdjustments(font, 36, 2.0, 'top')).toEqual({
topAdj: 8.373906,
bottomAdj: 0,
});
});
test('it gets a bottom adjustment when vertically aligning middle or bottom', () => {
expect(getBrowserVerticalFontAdjustments(font, 12, 1.0, 'bottom')).toEqual({
topAdj: 0,
bottomAdj: 2.791302,
});
expect(getBrowserVerticalFontAdjustments(font, 12, 1.15, 'middle')).toEqual({
topAdj: 0,
bottomAdj: 1.5916020000000004,
});
});
test('it does not get a bottom adjustment if the line height exceeds that of the font', () => {
expect(getBrowserVerticalFontAdjustments(font, 12, 1.35, 'bottom')).toEqual({
topAdj: 0,
bottomAdj: 0,
});
});
test('it does not get a bottom adjustment if the font base line-height is 1.0 or less', () => {
const thisFont = { ascent: 900, descent: -50, unitsPerEm: 1000 } as FontKitFont;
expect(getBrowserVerticalFontAdjustments(thisFont, 20, 1.0, 'bottom')).toEqual({
topAdj: 0,
bottomAdj: 0,
});
});
});
describe('filterStartJP', () => {
test('空の配列を渡すと空の配列を返す', () => {
expect(filterStartJP([])).toEqual([]);
});
test('禁則文字を含まない行はそのまま返す', () => {
const input = ['これは', '普通の', '文章です。'];
expect(filterStartJP(input)).toEqual(input);
});
test('行頭の禁則文字を前の行の末尾に移動する', () => {
const input = ['これは', '。文章', 'です'];
const expected = ['これは。', '文章', 'です'];
expect(filterStartJP(input)).toEqual(expected);
});
test('複数の禁則文字を正しく処理する', () => {
const input = ['これは', '。とても', '、長い', '」文章', 'です'];
const expected = ['これは。', 'とても、', '長い」', '文章', 'です'];
expect(filterStartJP(input)).toEqual(expected);
});
test('空の行を保持する', () => {
const input = ['これは', '', '。文章', 'です'];
const expected = ['これは。', '', '文章', 'です'];
expect(filterStartJP(input)).toEqual(expected);
});
test('1文字の行禁則文字のみはそのまま保持する', () => {
const input = ['これは', '。', '文章', 'です'];
// const expected = ['これは。', '文章', 'です'];
const expected = ['これは', '。', '文章', 'です'];
expect(filterStartJP(input)).toEqual(expected);
});
test('すべての禁則文字を正しく処理する', () => {
const input = LINE_START_FORBIDDEN_CHARS.map((char: string) => ['この', char + '文字']).flat();
const expected = LINE_START_FORBIDDEN_CHARS.map((char: string) => [
'この' + char,
'文字',
]).flat();
expect(filterStartJP(input)).toEqual(expected);
});
});
describe('filterEndJP', () => {
test('空の配列を渡すと空の配列を返す', () => {
expect(filterEndJP([])).toEqual([]);
});
test('禁則文字を含まない行はそのまま返す', () => {
const input = ['これは', '普通の', '文章です。'];
expect(filterEndJP(input)).toEqual(input);
});
test('行末の禁則文字を次の行の先頭に移動する', () => {
const input = ['これは「', '文章', 'です。'];
const expected = ['これは', '「文章', 'です。'];
expect(filterEndJP(input)).toEqual(expected);
});
test('複数の禁則文字を正しく処理する', () => {
const input = ['これは「', '長い『', '文章(', 'です。'];
const expected = ['これは', '「長い', '『文章', '(です。'];
expect(filterEndJP(input)).toEqual(expected);
});
// Cant understand purpose of this test...
// test('空の行を保持する', () => {
// const input = ['これは「', '', '文章', 'です。'];
// const expected = ['これは', '「', '', '文章', 'です。'];
// expect(filterEndJP(input)).toEqual(expected);
// });
test('1文字の行禁則文字のみはそのまま保持する', () => {
const input = ['これは', '「', '文章', 'です。'];
const expected = ['これは', '「', '文章', 'です。'];
expect(filterEndJP(input)).toEqual(expected);
});
test('すべての禁則文字を正しく処理する', () => {
const input = LINE_END_FORBIDDEN_CHARS.map((char: string) => ['これは' + char, '文章']).flat();
const expected = LINE_END_FORBIDDEN_CHARS.map((char: string) => [
'これは',
char + '文章',
]).flat();
expect(filterEndJP(input)).toEqual(expected);
});
test('最後の行の禁則文字は移動しない', () => {
const input = ['これは「', '文章「', 'です「'];
const expected = ['これは', '「文章', '「です「'];
expect(filterEndJP(input)).toEqual(expected);
});
});