From c10569c71428a821d3af87b0c2192975fdf1a064 Mon Sep 17 00:00:00 2001 From: Stefan Wittwer Date: Sun, 5 May 2024 11:34:08 +0200 Subject: [PATCH 1/2] feat(layout): support fontFeatureSettings in text rendering --- .changeset/six-paws-tie.md | 8 ++ .../examples/font-feature-settings/index.jsx | 86 +++++++++++++++++++ packages/examples/vite/src/examples/index.ts | 2 + .../layout/src/steps/resolveInheritance.ts | 1 + packages/layout/src/svg/inheritProps.ts | 1 + .../layout/src/text/getAttributedString.ts | 17 ++++ .../tests/steps/resolveInhritance.test.ts | 4 + .../tests/text/getAttributedString.test.ts | 40 +++++++++ packages/stylesheet/src/types.ts | 33 +++++++ packages/textkit/src/layout/generateGlyphs.ts | 4 +- packages/textkit/src/types.ts | 2 +- .../tests/layout/generateGlyphs.test.ts | 26 +++++- 12 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 .changeset/six-paws-tie.md create mode 100644 packages/examples/vite/src/examples/font-feature-settings/index.jsx create mode 100644 packages/layout/tests/text/getAttributedString.test.ts diff --git a/.changeset/six-paws-tie.md b/.changeset/six-paws-tie.md new file mode 100644 index 000000000..a5d88a78c --- /dev/null +++ b/.changeset/six-paws-tie.md @@ -0,0 +1,8 @@ +--- +"@react-pdf/textkit": minor +"@react-pdf/layout": minor +"@react-pdf/stylesheet": minor +"@react-pdf/types": minor +--- + +Add support for fontFeatureSettings to customize ligatures, tabular number display, and other font features. diff --git a/packages/examples/vite/src/examples/font-feature-settings/index.jsx b/packages/examples/vite/src/examples/font-feature-settings/index.jsx new file mode 100644 index 000000000..42d06d619 --- /dev/null +++ b/packages/examples/vite/src/examples/font-feature-settings/index.jsx @@ -0,0 +1,86 @@ +/* eslint react/prop-types: 0 */ +/* eslint react/jsx-sort-props: 0 */ + +import { Document, Font, Page, StyleSheet, Text } from '@react-pdf/renderer'; +import React from 'react'; + +import RobotoFont from '../../../public/Roboto-Regular.ttf'; +import RubikFont from '../../../public/Rubik-Regular.ttf'; + +const styles = StyleSheet.create({ + body: { + paddingTop: 35, + paddingBottom: 45, + paddingHorizontal: 35, + position: 'relative', + fontSize: 14, + }, + headline: { + fontFamily: 'Roboto', + fontSize: '24', + paddingVertical: 12, + }, + rubik: { + fontFamily: 'Rubik', + }, + roboto: { + fontFamily: 'Roboto', + }, + tabular: { + fontFeatureSettings: ['tnum'], + }, + smallCapitals: { + fontFeatureSettings: ['smcp'], + }, + disableCommonLigatures: { + fontFeatureSettings: { liga: 0 }, + }, +}); + +Font.register({ + family: 'Rubik', + fonts: [{ src: RubikFont, fontWeight: 400 }], +}); +Font.register({ + family: 'Roboto', + fonts: [{ src: RobotoFont, fontWeight: 400 }], +}); + +const MyDoc = () => { + const longNumberExample = "012'345'678'901"; + const commonLigaturesExample = 'A firefighter from Sheffield'; + return ( + + Rubik + {longNumberExample} – Default features + + {longNumberExample} – Tabular numbers + + Roboto + + {commonLigaturesExample} – Default features + + + {commonLigaturesExample} – Common ligatures off + + + {commonLigaturesExample} – Small capitals + + + ); +}; + +const FontFeatureSettings = () => { + return ( + + + + ); +}; + +export default { + id: 'font-feature-settings', + name: 'Font Feature Settings', + description: '', + Document: FontFeatureSettings, +}; diff --git a/packages/examples/vite/src/examples/index.ts b/packages/examples/vite/src/examples/index.ts index 733528bae..1ebd59692 100644 --- a/packages/examples/vite/src/examples/index.ts +++ b/packages/examples/vite/src/examples/index.ts @@ -3,6 +3,7 @@ import duplicatedImages from './duplicated-images'; import ellipsis from './ellipsis'; import emoji from './emoji'; import fontFamilyFallback from './font-family-fallback'; +import fontFeatureSettings from './font-feature-settings'; import fontWeight from './font-weight'; import goTo from './go-to'; import imageBackground from './image-background'; @@ -34,6 +35,7 @@ const EXAMPLES = [ emoji, fontFamilyFallback, fontWeight, + fontFeatureSettings, goTo, JpgOrientation, knobs, diff --git a/packages/layout/src/steps/resolveInheritance.ts b/packages/layout/src/steps/resolveInheritance.ts index 192a9ded7..937591d68 100644 --- a/packages/layout/src/steps/resolveInheritance.ts +++ b/packages/layout/src/steps/resolveInheritance.ts @@ -12,6 +12,7 @@ const BASE_INHERITABLE_PROPERTIES = [ 'fontSize', 'fontStyle', 'fontWeight', + 'fontFeatureSettings', 'letterSpacing', 'opacity', 'textDecoration', diff --git a/packages/layout/src/svg/inheritProps.ts b/packages/layout/src/svg/inheritProps.ts index 0684bce65..2ac618e70 100644 --- a/packages/layout/src/svg/inheritProps.ts +++ b/packages/layout/src/svg/inheritProps.ts @@ -23,6 +23,7 @@ const BASE_SVG_INHERITED_PROPS = [ 'fontSize', 'fontStyle', 'fontWeight', + 'fontFeatureSettings', 'letterSpacing', 'opacity', 'textDecoration', diff --git a/packages/layout/src/text/getAttributedString.ts b/packages/layout/src/text/getAttributedString.ts index abab6c3c9..cb37061e2 100644 --- a/packages/layout/src/text/getAttributedString.ts +++ b/packages/layout/src/text/getAttributedString.ts @@ -1,6 +1,7 @@ import * as P from '@react-pdf/primitives'; import { Fragment, fromFragments } from '@react-pdf/textkit'; import FontStore from '@react-pdf/font'; +import type { FontFeatureSettings } from '@react-pdf/stylesheet'; import { embedEmojis } from './emoji'; import ignoreChars from './ignoreChars'; @@ -24,6 +25,20 @@ const isRasterImage = (node: SafeImageNode): boolean => const isTextInstance = (node: SafeNode): node is SafeTextInstanceNode => node.type === P.TextInstance; +const transformFontFeatureSettings = ( + fontFeatureSettings?: FontFeatureSettings, +): string[] | Record | undefined => { + if (!fontFeatureSettings) return undefined; + if (Array.isArray(fontFeatureSettings)) return fontFeatureSettings; + + return Object.fromEntries( + Object.entries(fontFeatureSettings).map(([key, value]) => [ + key, + value !== 0, + ]), + ); +}; + /** * Get textkit fragments of given node object * @@ -50,6 +65,7 @@ const getFragments = ( fontWeight, fontStyle, fontSize = 18, + fontFeatureSettings, textAlign, lineHeight, textDecoration, @@ -103,6 +119,7 @@ const getFragments = ( // @ts-expect-error allow this props access link: parentLink || instance.props?.src || instance.props?.href, align: textAlign || (direction === 'rtl' ? 'right' : 'left'), + features: transformFontFeatureSettings(fontFeatureSettings), }; for (let i = 0; i < instance.children.length; i += 1) { diff --git a/packages/layout/tests/steps/resolveInhritance.test.ts b/packages/layout/tests/steps/resolveInhritance.test.ts index 2f5ffbce8..474d58a78 100644 --- a/packages/layout/tests/steps/resolveInhritance.test.ts +++ b/packages/layout/tests/steps/resolveInhritance.test.ts @@ -175,4 +175,8 @@ describe('layout resolveInheritance', () => { test('Should inherit textAlign value', shouldInherit('textAlign')); test('Should inherit visibility value', shouldInherit('visibility')); test('Should inherit wordSpacing value', shouldInherit('wordSpacing')); + test( + 'Should inherit fontFeatureSettings value', + shouldInherit('fontFeatureSettings'), + ); }); diff --git a/packages/layout/tests/text/getAttributedString.test.ts b/packages/layout/tests/text/getAttributedString.test.ts new file mode 100644 index 000000000..1ebbf6506 --- /dev/null +++ b/packages/layout/tests/text/getAttributedString.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; +import FontStore from '@react-pdf/font'; +import type { FontFeatureSettings } from '@react-pdf/stylesheet'; + +import getAttributedString from '../../src/text/getAttributedString'; +import { SafeTextNode } from '../../src/types'; + +const font = {}; + +const fontStore = { + getFont: () => ({ data: font }), +} as unknown as FontStore; + +const getTextNode = (fontFeatureSettings: FontFeatureSettings): SafeTextNode => + ({ + type: 'TEXT', + props: {}, + style: { fontFeatureSettings }, + children: [{ type: 'TEXT_INSTANCE', value: 'Lorem' }], + }) as SafeTextNode; + +describe('getAttributedString', () => { + test('should map numeric font feature settings to boolean features', () => { + const result = getAttributedString( + fontStore, + getTextNode({ liga: 0, kern: 1 }), + ); + + expect(result.runs[0].attributes.features).toEqual({ + liga: false, + kern: true, + }); + }); + + test('should preserve font feature setting arrays', () => { + const result = getAttributedString(fontStore, getTextNode(['tnum'])); + + expect(result.runs[0].attributes.features).toEqual(['tnum']); + }); +}); diff --git a/packages/stylesheet/src/types.ts b/packages/stylesheet/src/types.ts index e7bd5adc6..ca418dcca 100644 --- a/packages/stylesheet/src/types.ts +++ b/packages/stylesheet/src/types.ts @@ -321,12 +321,45 @@ export type TextTransform = export type VerticalAlign = 'sub' | 'super'; +export type FontFeatureSetting = + | 'liga' + | 'dlig' + | 'onum' + | 'lnum' + | 'tnum' + | 'zero' + | 'frac' + | 'sups' + | 'subs' + | 'smcp' + | 'c2sc' + | 'case' + | 'hlig' + | 'calt' + | 'swsh' + | 'hist' + | `ss${'01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '10' | '11' | '12' | '13' | '14' | '15' | '16' | '17' | '18' | '19' | '20'}` + | 'kern' + | 'locl' + | 'rlig' + | 'medi' + | 'init' + | 'isol' + | 'fina' + | 'mark' + | 'mkmk'; + +export type FontFeatureSettings = + | FontFeatureSetting[] + | Partial>; + export type TextStyle = { direction?: 'ltr' | 'rtl'; fontSize?: number | string; fontFamily?: string | string[]; fontStyle?: FontStyle; fontWeight?: FontWeight; + fontFeatureSettings?: FontFeatureSettings; letterSpacing?: number | string; lineHeight?: number | string; maxLines?: number | string; diff --git a/packages/textkit/src/layout/generateGlyphs.ts b/packages/textkit/src/layout/generateGlyphs.ts index 88e1380a6..722654e97 100644 --- a/packages/textkit/src/layout/generateGlyphs.ts +++ b/packages/textkit/src/layout/generateGlyphs.ts @@ -43,7 +43,7 @@ const layoutRun = (string: string) => { */ return (run: Run) => { const { start, end, attributes = {} } = run; - const { font } = attributes; + const { font, features } = attributes; if (!font) return { @@ -61,7 +61,7 @@ const layoutRun = (string: string) => { // passing LTR To force fontkit to not reverse the string const glyphRun = font[0].layout( runString, - undefined, + features, undefined, undefined, 'ltr', diff --git a/packages/textkit/src/types.ts b/packages/textkit/src/types.ts index fa31bf753..d3602ddc2 100644 --- a/packages/textkit/src/types.ts +++ b/packages/textkit/src/types.ts @@ -51,7 +51,7 @@ export type Attributes = { characterSpacing?: number; color?: string; direction?: 'rtl' | 'ltr'; - features?: unknown[]; + features?: string[] | Record; fill?: boolean; font?: Font[]; fontSize?: number; diff --git a/packages/textkit/tests/layout/generateGlyphs.test.ts b/packages/textkit/tests/layout/generateGlyphs.test.ts index 2c9dcaaa5..b2440a5b5 100644 --- a/packages/textkit/tests/layout/generateGlyphs.test.ts +++ b/packages/textkit/tests/layout/generateGlyphs.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import font from '../internal/font'; import pluck from '../internal/pluck'; @@ -7,6 +7,30 @@ import generateGlyphs from '../../src/layout/generateGlyphs'; const instance = generateGlyphs(); describe('generateGlyphs', () => { + test('should pass features to font layout', () => { + const features = { liga: false }; + const layout = vi.fn(font.layout); + + instance({ + string: 'Lorem', + runs: [ + { + start: 0, + end: 5, + attributes: { font: [{ ...font, layout }], features }, + }, + ], + }); + + expect(layout).toHaveBeenCalledWith( + 'Lorem', + features, + undefined, + undefined, + 'ltr', + ); + }); + test('should return empty glyphs if font not provided', () => { const result = instance({ string: 'Lorem ipsum', From fb3650b01b3de7c2da09ea4f752c76d2dc4ef16e Mon Sep 17 00:00:00 2001 From: Manuel Meister Date: Thu, 11 Jun 2026 15:51:52 +0200 Subject: [PATCH 2/2] Update README for fontFeatureSettings --- packages/stylesheet/README.md | 35 ++++++++++++++++++++++++++++++++++- packages/textkit/README.md | 15 +++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/stylesheet/README.md b/packages/stylesheet/README.md index 6f817a5a7..ca5ddfee7 100644 --- a/packages/stylesheet/README.md +++ b/packages/stylesheet/README.md @@ -458,6 +458,7 @@ type Style = { fontSize?: number | string; fontStyle?: 'normal' | 'italic' | 'oblique'; fontWeight?: FontWeight; + fontFeatureSettings?: FontFeatureSettings; letterSpacing?: number | string; lineHeight?: number | string; textAlign?: 'left' | 'right' | 'center' | 'justify'; @@ -543,7 +544,7 @@ type Transform = }; ``` -### FontWeight +### FontWeight & FontFeatureSettings ```ts type FontWeight = @@ -562,6 +563,38 @@ type FontWeight = | 'extrabold' | 'heavy' | 'black'; + +type FontFeatureSetting = + | 'liga' + | 'dlig' + | 'onum' + | 'lnum' + | 'tnum' + | 'zero' + | 'frac' + | 'sups' + | 'subs' + | 'smcp' + | 'c2sc' + | 'case' + | 'hlig' + | 'calt' + | 'swsh' + | 'hist' + | `ss${'01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '10' | '11' | '12' | '13' | '14' | '15' | '16' | '17' | '18' | '19' | '20'}` + | 'kern' + | 'locl' + | 'rlig' + | 'medi' + | 'init' + | 'isol' + | 'fina' + | 'mark' + | 'mkmk'; + +type FontFeatureSettings = + | FontFeatureSetting[] + | Partial>; ``` ## License diff --git a/packages/textkit/README.md b/packages/textkit/README.md index 6abbcabf0..c08e3bd00 100644 --- a/packages/textkit/README.md +++ b/packages/textkit/README.md @@ -6,7 +6,7 @@ > An advanced text layout framework -A comprehensive text layout engine for react-pdf. Handles complex text rendering including bidirectional text, line breaking, hyphenation, justification, font substitution, and text decoration. +A comprehensive text layout engine for react-pdf. Handles complex text rendering including bidirectional text, line breaking, hyphenation, justification, font substitution, OpenType font features, and text decoration. ## Acknowledges @@ -46,7 +46,14 @@ const engines = { // Create attributed string from fragments const attributedString = fromFragments([ { string: 'Hello ', attributes: { fontSize: 12, font: [myFont] } }, - { string: 'World!', attributes: { fontSize: 12, font: [myFont] } }, + { + string: "012'345'678'901", + attributes: { + fontSize: 12, + font: [myFont], + features: ['tnum'], + }, + }, ]); // Define container @@ -70,7 +77,7 @@ The layout engine processes text through the following steps: 2. Get bidi runs and paragraph direction 3. Font substitution - map to resolved font runs 4. Script itemization -5. Font shaping - text to glyphs +5. Font shaping - text to glyphs, applying optional OpenType font features 6. Line breaking 7. Bidi reordering 8. Justification @@ -234,7 +241,7 @@ type Attributes = { characterSpacing?: number; color?: string; direction?: 'rtl' | 'ltr'; - features?: unknown[]; + features?: string[] | Record; fill?: boolean; font?: Font[]; fontSize?: number;