fix: Fixes rendering of non-Latin alphabet characters in PDF export (#2948)

* fix: Fixes rendering of non-Latin alphabet characters in PDF export

* chore: self-host font files

* chore: add fonts license

* fix: use assets base URL instead of bundling fonts with app

* chore: delete unused assets folder

* fix: remove inexistent family fonts
This commit is contained in:
Antonella Sgarlatta
2025-10-27 19:59:20 -03:00
parent d464fd01a0
commit 4c1896208f
43 changed files with 495 additions and 147 deletions

View File

@@ -116,9 +116,10 @@
"@lexical/rich-text": "0.32.1",
"@lexical/utils": "0.32.1",
"@radix-ui/react-slot": "^1.0.1",
"@react-pdf/renderer": "^3.3.2",
"@react-pdf/renderer": "^4.3.0",
"comlink": "^4.4.1",
"fast-diff": "^1.3.0",
"lexical": "0.32.1"
"lexical": "0.32.1",
"unicode-script": "^1.2.0"
}
}

View File

@@ -0,0 +1,284 @@
import { Font } from '@react-pdf/renderer'
import { LexicalNode } from 'lexical'
// @ts-expect-error No typing for this package
import { unicodeScripts } from 'unicode-script'
enum UnicodeScript {
Latin = 'Latin',
Common = 'Common',
Cyrillic = 'Cyrillic',
Greek = 'Greek',
Hebrew = 'Hebrew',
Arabic = 'Arabic',
Devanagari = 'Devanagari',
Bengali = 'Bengali',
Tamil = 'Tamil',
Telugu = 'Telugu',
Gujarati = 'Gujarati',
Gurmukhi = 'Gurmukhi',
Malayalam = 'Malayalam',
Sinhala = 'Sinhala',
Thai = 'Thai',
Armenian = 'Armenian',
Georgian = 'Georgian',
Ethiopic = 'Ethiopic',
Myanmar = 'Myanmar',
Khmer = 'Khmer',
Lao = 'Lao',
Tibetan = 'Tibetan',
Vietnamese = 'Vietnamese',
Chinese = 'Chinese',
Han = 'Han',
Japanese = 'Japanese',
Korean = 'Korean',
Hangul = 'Hangul',
}
export enum FontFamily {
NotoSans = 'Noto Sans',
NotoSansHebrew = 'Noto Sans Hebrew',
NotoSansArabic = 'Noto Sans Arabic',
NotoSansDevanagari = 'Noto Sans Devanagari',
NotoSansBengali = 'Noto Sans Bengali',
NotoSansTamil = 'Noto Sans Tamil',
NotoSansTelugu = 'Noto Sans Telugu',
NotoSansGujarati = 'Noto Sans Gujarati',
NotoSansGurmukhi = 'Noto Sans Gurmukhi',
NotoSansMalayalam = 'Noto Sans Malayalam',
NotoSansSinhala = 'Noto Sans Sinhala',
NotoSansThai = 'Noto Sans Thai',
NotoSansArmenian = 'Noto Sans Armenian',
NotoSansGeorgian = 'Noto Sans Georgian',
NotoSansEthiopic = 'Noto Sans Ethiopic',
NotoSansMyanmar = 'Noto Sans Myanmar',
NotoSansKhmer = 'Noto Sans Khmer',
NotoSansLao = 'Noto Sans Lao',
NotoSansTibetan = 'Noto Sans Tibetan',
NotoSansSC = 'Noto Sans SC',
NotoSansJP = 'Noto Sans JP',
NotoSansKR = 'Noto Sans KR',
Courier = 'Courier',
Helvetica = 'Helvetica',
}
enum FontVariant {
Normal = 'normal',
Bold = 'bold',
Italic = 'italic',
BoldItalic = 'bolditalic',
}
type FontWeight = 'normal' | 'bold'
type FontStyle = 'normal' | 'italic'
const FONT_VARIANT_TO_FONT_OPTIONS: Record<FontVariant, { fontWeight: FontWeight; fontStyle: FontStyle }> = {
[FontVariant.Normal]: {
fontWeight: 'normal',
fontStyle: 'normal',
},
[FontVariant.Bold]: {
fontWeight: 'bold',
fontStyle: 'normal',
},
[FontVariant.Italic]: {
fontWeight: 'normal',
fontStyle: 'italic',
},
[FontVariant.BoldItalic]: {
fontWeight: 'bold',
fontStyle: 'italic',
},
}
const FONT_ASSETS_BASE_PATH = 'https://assets.standardnotes.com/fonts'
const FALLBACK_FONT_SOURCE = '/noto-sans/NotoSans-Regular.ttf'
export const FALLBACK_FONT_FAMILY = FontFamily.Helvetica
export const MONOSPACE_FONT_FAMILY = FontFamily.Courier
const FONT_FAMILY_TO_FONT_SOURCES: Partial<Record<FontFamily, Partial<Record<FontVariant, string>>>> = {
[FontFamily.NotoSans]: {
[FontVariant.Normal]: '/noto-sans/NotoSans-Regular.ttf',
[FontVariant.Bold]: '/noto-sans/NotoSans-Bold.ttf',
[FontVariant.Italic]: '/noto-sans/NotoSans-Italic.ttf',
[FontVariant.BoldItalic]: '/noto-sans/NotoSans-BoldItalic.ttf',
},
[FontFamily.NotoSansHebrew]: {
[FontVariant.Normal]: '/noto-sans-hebrew/NotoSansHebrew-Regular.ttf',
[FontVariant.Bold]: '/noto-sans-hebrew/NotoSansHebrew-Bold.ttf',
},
[FontFamily.NotoSansArabic]: {
[FontVariant.Normal]: '/noto-sans-arabic/NotoSansArabic-Regular.ttf',
[FontVariant.Bold]: '/noto-sans-arabic/NotoSansArabic-Bold.ttf',
},
[FontFamily.NotoSansDevanagari]: {
[FontVariant.Normal]: '/noto-sans-devanagari/NotoSansDevanagari-Regular.ttf',
[FontVariant.Bold]: '/noto-sans-devanagari/NotoSansDevanagari-Bold.ttf',
},
[FontFamily.NotoSansBengali]: {
[FontVariant.Normal]: '/noto-sans-bengali/NotoSansBengali-Regular.ttf',
},
[FontFamily.NotoSansTamil]: {
[FontVariant.Normal]: '/noto-sans-tamil/NotoSansTamil-Regular.ttf',
},
[FontFamily.NotoSansTelugu]: {
[FontVariant.Normal]: '/noto-sans-telugu/NotoSansTelugu-Regular.ttf',
},
[FontFamily.NotoSansGujarati]: {
[FontVariant.Normal]: '/noto-sans-gujarati/NotoSansGujarati-Regular.ttf',
},
[FontFamily.NotoSansGurmukhi]: {
[FontVariant.Normal]: '/noto-sans-gurmukhi/NotoSansGurmukhi-Regular.ttf',
},
[FontFamily.NotoSansMalayalam]: {
[FontVariant.Normal]: '/noto-sans-malayalam/NotoSansMalayalam-Regular.ttf',
},
[FontFamily.NotoSansSinhala]: {
[FontVariant.Normal]: '/noto-sans-sinhala/NotoSansSinhala-Regular.ttf',
},
[FontFamily.NotoSansThai]: {
[FontVariant.Normal]: '/noto-sans-thai/NotoSansThai-Regular.ttf',
},
[FontFamily.NotoSansArmenian]: {
[FontVariant.Normal]: '/noto-sans-armenian/NotoSansArmenian-Regular.ttf',
},
[FontFamily.NotoSansGeorgian]: {
[FontVariant.Normal]: '/noto-sans-georgian/NotoSansGeorgian-Regular.ttf',
},
[FontFamily.NotoSansEthiopic]: {
[FontVariant.Normal]: '/noto-sans-ethiopic/NotoSansEthiopic-Regular.ttf',
},
[FontFamily.NotoSansMyanmar]: {
[FontVariant.Normal]: '/noto-sans-myanmar/NotoSansMyanmar-Regular.ttf',
},
[FontFamily.NotoSansKhmer]: {
[FontVariant.Normal]: '/noto-sans-khmer/NotoSansKhmer-Regular.ttf',
},
[FontFamily.NotoSansLao]: {
[FontVariant.Normal]: '/noto-sans-lao/NotoSansLao-Regular.ttf',
},
[FontFamily.NotoSansTibetan]: {
[FontVariant.Normal]: '/noto-sans-tibetan/NotoSansTibetan-Regular.ttf',
},
[FontFamily.NotoSansSC]: {
[FontVariant.Normal]: '/noto-sans-sc/NotoSansSC-Regular.ttf',
},
[FontFamily.NotoSansJP]: {
[FontVariant.Normal]: '/noto-sans-jp/NotoSansJP-Regular.ttf',
},
[FontFamily.NotoSansKR]: {
[FontVariant.Normal]: '/noto-sans-kr/NotoSansKR-Regular.ttf',
},
}
export const getFontFamilyForUnicodeScript = (script: UnicodeScript): FontFamily => {
switch (script) {
case UnicodeScript.Common:
case UnicodeScript.Latin:
case UnicodeScript.Cyrillic:
case UnicodeScript.Greek:
case UnicodeScript.Vietnamese:
return FontFamily.NotoSans
case UnicodeScript.Hebrew:
return FontFamily.NotoSansHebrew
case UnicodeScript.Arabic:
return FontFamily.NotoSansArabic
case UnicodeScript.Devanagari:
return FontFamily.NotoSansDevanagari
case UnicodeScript.Bengali:
return FontFamily.NotoSansBengali
case UnicodeScript.Tamil:
return FontFamily.NotoSansTamil
case UnicodeScript.Telugu:
return FontFamily.NotoSansTelugu
case UnicodeScript.Gujarati:
return FontFamily.NotoSansGujarati
case UnicodeScript.Gurmukhi:
return FontFamily.NotoSansGurmukhi
case UnicodeScript.Malayalam:
return FontFamily.NotoSansMalayalam
case UnicodeScript.Sinhala:
return FontFamily.NotoSansSinhala
case UnicodeScript.Thai:
return FontFamily.NotoSansThai
case UnicodeScript.Armenian:
return FontFamily.NotoSansArmenian
case UnicodeScript.Georgian:
return FontFamily.NotoSansGeorgian
case UnicodeScript.Ethiopic:
return FontFamily.NotoSansEthiopic
case UnicodeScript.Myanmar:
return FontFamily.NotoSansMyanmar
case UnicodeScript.Khmer:
return FontFamily.NotoSansKhmer
case UnicodeScript.Lao:
return FontFamily.NotoSansLao
case UnicodeScript.Tibetan:
return FontFamily.NotoSansTibetan
case UnicodeScript.Chinese:
case UnicodeScript.Han:
return FontFamily.NotoSansSC
case UnicodeScript.Japanese:
return FontFamily.NotoSansJP
case UnicodeScript.Korean:
case UnicodeScript.Hangul:
return FontFamily.NotoSansKR
default:
return FontFamily.NotoSans
}
}
const getFontRegisterOptions = (fontFamily: FontFamily) => {
const fallback = FONT_FAMILY_TO_FONT_SOURCES[fontFamily]?.[FontVariant.Normal] ?? FALLBACK_FONT_SOURCE
return {
family: fontFamily,
fonts: Object.entries(FONT_VARIANT_TO_FONT_OPTIONS).map(([variant, fontOptions]) => ({
...fontOptions,
src: `${FONT_ASSETS_BASE_PATH}${FONT_FAMILY_TO_FONT_SOURCES[fontFamily]?.[variant as FontVariant] ?? fallback}`,
})),
}
}
export const getFontFamiliesFromLexicalNode = (node: LexicalNode) => {
const scripts: UnicodeScript[] = Array.from(unicodeScripts(node.getTextContent()))
const fontFamilies = [FontFamily.NotoSans]
scripts.forEach((script) => {
const fontFamilyForScript = getFontFamilyForUnicodeScript(script)
if (!fontFamilies.includes(fontFamilyForScript)) {
fontFamilies.unshift(fontFamilyForScript)
}
})
const fontFamiliesSet = new Set(fontFamilies)
return Array.from(fontFamiliesSet)
}
export const registerPDFFonts = (fontFamilies: FontFamily[]) => {
const fontFamiliesToRegister = new Set(fontFamilies)
fontFamiliesToRegister.forEach((fontFamily) => {
const registerOptions = getFontRegisterOptions(fontFamily)
Font.register(registerOptions)
})
}

View File

@@ -24,6 +24,7 @@ import { $isCollapsibleTitleNode } from '../../../Plugins/CollapsiblePlugin/Coll
import PDFWorker, { PDFDataNode, PDFWorkerInterface } from './PDFWorker.worker'
import { wrap } from 'comlink'
import { PrefKey, PrefValue } from '@standardnotes/snjs'
import { FALLBACK_FONT_FAMILY, FontFamily, MONOSPACE_FONT_FAMILY, getFontFamiliesFromLexicalNode } from './FontConfig'
const styles = StyleSheet.create({
page: {
@@ -144,6 +145,12 @@ const getFontSizeForHeading = (heading: HeadingNode) => {
}
const getNodeTextAlignment = (node: ElementNode) => {
const direction = node.getDirection()
if (direction === 'rtl') {
return 'right'
}
const formatType = node.getFormatType()
if (!formatType) {
@@ -161,7 +168,12 @@ const getNodeTextAlignment = (node: ElementNode) => {
return formatType
}
const getPDFDataNodeFromLexicalNode = (node: LexicalNode): PDFDataNode => {
const getNodeDirection = (node: ElementNode) => {
const direction = node.getDirection()
return direction ?? 'ltr'
}
const getPDFDataNodeFromLexicalNode = (node: LexicalNode, fontFamilies: FontFamily[]): PDFDataNode => {
const parent = node.getParent()
if ($isLineBreakNode(node)) {
@@ -177,23 +189,23 @@ const getPDFDataNodeFromLexicalNode = (node: LexicalNode): PDFDataNode => {
const isBold = node.hasFormat('bold')
const isItalic = node.hasFormat('italic')
const isHighlight = node.hasFormat('highlight')
const nodeFontFamilies = getFontFamiliesFromLexicalNode(node)
let fontFamily: FontFamily[] | FontFamily = [...nodeFontFamilies, FALLBACK_FONT_FAMILY]
let font = isInlineCode || isCodeNodeText ? 'Courier' : 'Helvetica'
if (isBold || isItalic) {
font += '-'
if (isBold) {
font += 'Bold'
}
if (isItalic) {
font += 'Oblique'
}
if (isInlineCode && isCodeNodeText) {
fontFamily = MONOSPACE_FONT_FAMILY
} else {
fontFamilies.push(...nodeFontFamilies)
}
return {
type: 'Text',
children: node.getTextContent(),
style: {
fontFamily: font,
fontFamily,
fontWeight: isBold ? 'bold' : 'normal',
fontStyle: isItalic ? 'italic' : 'normal',
direction: $isElementNode(parent) ? getNodeDirection(parent) : 'ltr',
textDecoration: node.hasFormat('underline')
? 'underline'
: node.hasFormat('strikethrough')
@@ -237,7 +249,7 @@ const getPDFDataNodeFromLexicalNode = (node: LexicalNode): PDFDataNode => {
type: 'View',
style: [styles.row, styles.wrap],
children: line.map((child) => {
return getPDFDataNodeFromLexicalNode(child)
return getPDFDataNodeFromLexicalNode(child, fontFamilies)
}),
}
}),
@@ -267,7 +279,7 @@ const getPDFDataNodeFromLexicalNode = (node: LexicalNode): PDFDataNode => {
const children =
$isElementNode(node) || $isTableNode(node) || $isTableCellNode(node) || $isTableRowNode(node)
? node.getChildren().map((child) => {
return getPDFDataNodeFromLexicalNode(child)
return getPDFDataNodeFromLexicalNode(child, fontFamilies)
})
: undefined
@@ -427,8 +439,8 @@ const getPDFDataNodeFromLexicalNode = (node: LexicalNode): PDFDataNode => {
}
}
const getPDFDataNodesFromLexicalNodes = (nodes: LexicalNode[]): PDFDataNode[] => {
return nodes.map(getPDFDataNodeFromLexicalNode)
const getPDFDataNodesFromLexicalNodes = (nodes: LexicalNode[], fontFamilies: FontFamily[]): PDFDataNode[] => {
return nodes.map((node) => getPDFDataNodeFromLexicalNode(node, fontFamilies))
}
const pdfWorker = new PDFWorker()
@@ -438,17 +450,21 @@ const PDFWorkerComlink = wrap<PDFWorkerInterface>(pdfWorker)
* @returns The PDF as an object url
*/
export function $generatePDFFromNodes(editor: LexicalEditor, pageSize: PrefValue[PrefKey.SuperNoteExportPDFPageSize]) {
return new Promise<string>((resolve) => {
return new Promise<string>((resolve, reject) => {
editor.getEditorState().read(() => {
const root = $getRoot()
const nodes = root.getChildren()
const fontFamilies: FontFamily[] = []
const pdfDataNodes = getPDFDataNodesFromLexicalNodes(nodes, fontFamilies)
const pdfDataNodes = getPDFDataNodesFromLexicalNodes(nodes)
void PDFWorkerComlink.renderPDF(pdfDataNodes, pageSize).then((blob) => {
const url = URL.createObjectURL(blob)
resolve(url)
})
void PDFWorkerComlink.renderPDF(pdfDataNodes, pageSize, fontFamilies)
.then((blob) => {
const url = URL.createObjectURL(blob)
resolve(url)
})
.catch((error) => {
reject(error)
})
})
})
}

View File

@@ -17,6 +17,7 @@ import {
PageProps,
} from '@react-pdf/renderer'
import { expose } from 'comlink'
import { FontFamily, registerPDFFonts } from './FontConfig'
export type PDFDataNode =
| ((
@@ -94,7 +95,8 @@ const PDFDocument = ({ nodes, pageSize }: { nodes: PDFDataNode[]; pageSize: Page
)
}
const renderPDF = (nodes: PDFDataNode[], pageSize: PageProps['size']) => {
const renderPDF = (nodes: PDFDataNode[], pageSize: PageProps['size'], fontFamilies: FontFamily[]) => {
registerPDFFonts(fontFamilies)
return pdf(<PDFDocument pageSize={pageSize} nodes={nodes} />).toBlob()
}

View File

@@ -64,6 +64,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
}
},
): Promise<string> {
let didThrow = false
if (superString.length === 0) {
return superString
}
@@ -81,7 +82,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
let content: string | undefined
await new Promise<void>((resolve) => {
await new Promise<void>((resolve, reject) => {
const handleFileNodes = () => {
if (embedBehavior === 'reference') {
resolve()
@@ -136,12 +137,16 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
}),
)
.then(() => resolve())
.catch(console.error)
.catch((error) => {
didThrow = true
console.error(error)
reject(error)
})
}
this.exportEditor.update(handleFileNodes, { discrete: true })
})
await new Promise<void>((resolve) => {
await new Promise<void>((resolve, reject) => {
const convertToFormat = () => {
switch (toFormat) {
case 'txt':
@@ -164,10 +169,16 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
break
case 'pdf': {
void import('../Lexical/Utils/PDFExport/PDFExport').then(({ $generatePDFFromNodes }): void => {
void $generatePDFFromNodes(this.exportEditor, config?.pdf?.pageSize || 'A4').then((pdf) => {
content = pdf
resolve()
})
void $generatePDFFromNodes(this.exportEditor, config?.pdf?.pageSize || 'A4')
.then((pdf) => {
content = pdf
resolve()
})
.catch((error) => {
didThrow = true
console.error(error)
reject(error)
})
})
break
}
@@ -181,7 +192,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
this.exportEditor.update(convertToFormat, { discrete: true })
})
if (typeof content !== 'string') {
if (didThrow || typeof content !== 'string') {
throw new Error('Could not export note')
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable */
const path = require('path')
const webpack = require('webpack')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')