Skip to content

Commit

Permalink
feat: add bidi support (#2600)
Browse files Browse the repository at this point in the history
Co-authored-by: Ahmed <ahmed@novalabs-qa.com>
  • Loading branch information
diegomura and ahmed-novalabs committed Feb 5, 2024
1 parent 9af07fe commit 8350154
Show file tree
Hide file tree
Showing 27 changed files with 602 additions and 18 deletions.
9 changes: 9 additions & 0 deletions .changeset/serious-teachers-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@react-pdf/textkit": minor
"@react-pdf/layout": minor
"@react-pdf/fns": minor
"@react-pdf/pdfkit": patch
"@react-pdf/image": patch
---

feat: bidi support
1 change: 1 addition & 0 deletions packages/fns/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ export { default as mapValues } from './mapValues';
export { default as matchPercent } from './matchPercent';
export { default as omit } from './omit';
export { default as pick } from './pick';
export { default as repeat } from './repeat';
export { default as reverse } from './reverse';
export { default as upperFirst } from './upperFirst';
9 changes: 9 additions & 0 deletions packages/fns/src/repeat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const repeat = (list, length = 0) => {
const result = new Array(length);
for (let i = 0; i < length; i += 1) {
result[i] = list;
}
return result;
};

export default repeat;
17 changes: 17 additions & 0 deletions packages/fns/tests/repeat.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';

import repeat from '../src/repeat';

describe('repeat', () => {
test('should repeat property times', () => {
expect(repeat('a', 5)).toEqual(['a', 'a', 'a', 'a', 'a']);
expect(repeat('b', 0)).toEqual([]);
expect(repeat('Lorem', 1)).toEqual(['Lorem']);
expect(repeat('Ipsum', 2)).toEqual(['Ipsum', 'Ipsum']);
expect(repeat(undefined, 3)).toEqual([undefined, undefined, undefined]);
});

test('should not repeat property', () => {
expect(repeat('Lorem')).toEqual([]);
});
});
4 changes: 2 additions & 2 deletions packages/image/src/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const resolveBufferImage = (buffer) => {
return Promise.resolve();
};

const resolveBlobImage = async blob => {
const resolveBlobImage = async (blob) => {
const { type } = blob;
if (!type || type === 'application/octet-stream') {
const arrayBuffer = await blob.arrayBuffer();
Expand All @@ -146,7 +146,7 @@ const resolveBlobImage = async blob => {
return getImage(Buffer.from(buffer), format);
};

const getImageFormat = body => {
const getImageFormat = (body) => {
const isPng =
body[0] === 137 &&
body[1] === 80 &&
Expand Down
2 changes: 2 additions & 0 deletions packages/layout/src/svg/layoutText.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as P from '@react-pdf/primitives';
import layoutEngine, {
bidi,
linebreaker,
justification,
scriptItemizer,
Expand All @@ -14,6 +15,7 @@ import fontSubstitution from '../text/fontSubstitution';
const isTextInstance = (node) => node.type === P.TextInstance;

const engines = {
bidi,
linebreaker,
justification,
textDecoration,
Expand Down
8 changes: 5 additions & 3 deletions packages/layout/src/text/getAttributedString.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ const getFragments = (fontStore, instance, parentLink, level = 0) => {

const {
color = 'black',
direction = 'ltr',
fontFamily = 'Helvetica',
fontWeight,
fontStyle,
fontSize = 18,
textAlign = 'left',
textAlign,
lineHeight,
textDecoration,
textDecorationColor,
Expand All @@ -55,8 +56,9 @@ const getFragments = (fontStore, instance, parentLink, level = 0) => {
color,
opacity,
fontSize,
direction,
verticalAlign,
backgroundColor,
align: textAlign,
indent: textIndent,
characterSpacing: letterSpacing,
strikeStyle: textDecorationStyle,
Expand All @@ -73,7 +75,7 @@ const getFragments = (fontStore, instance, parentLink, level = 0) => {
underlineColor: textDecorationColor || color,
link: parentLink || instance.props?.src || instance.props?.href,
lineHeight: lineHeight ? lineHeight * fontSize : null,
verticalAlign,
align: textAlign || (direction === 'rtl' ? 'right' : 'left'),
};

for (let i = 0; i < instance.children.length; i += 1) {
Expand Down
2 changes: 2 additions & 0 deletions packages/layout/src/text/layoutText.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import layoutEngine, {
bidi,
linebreaker,
justification,
scriptItemizer,
Expand All @@ -10,6 +11,7 @@ import fontSubstitution from './fontSubstitution';
import getAttributedString from './getAttributedString';

const engines = {
bidi,
linebreaker,
justification,
textDecoration,
Expand Down
3 changes: 2 additions & 1 deletion packages/pdfkit/src/font/embedded.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ const createEmbeddedFont = (PDFFont) =>
}

layoutRun(text, features) {
const run = this.font.layout(text, features);
// passing LTR To force fontkit to not reverse the string
const run = this.font.layout(text, features, undefined, undefined, 'ltr');

// Normalize position values
for (let i = 0; i < run.positions.length; i++) {
Expand Down
1 change: 1 addition & 0 deletions packages/textkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "2.1.0",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
Expand Down
1 change: 1 addition & 0 deletions packages/textkit/src/attributedString/fromFragments.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const fromFragments = (fragments) => {
string += fragment.string;

runs.push({
...fragment,
start: offset,
end: offset + fragment.string.length,
attributes: fragment.attributes || {},
Expand Down
51 changes: 51 additions & 0 deletions packages/textkit/src/engines/bidi/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import bidiFactory from 'bidi-js';

const bidi = bidiFactory();

/**
* @param {Object} layout options
* @param {Object} attributed string
* @return {Object} attributed string
*/
const bidiEngine = () => (attributedString) => {
const { string } = attributedString;
const direction = attributedString.runs[0]?.attributes.direction;

const { levels } = bidi.getEmbeddingLevels(string, direction);

let lastLevel = null;
let lastIndex = 0;
let index = 0;
const res = [];

for (let i = 0; i < levels.length; i += 1) {
const level = levels[i];

if (level !== lastLevel) {
if (lastLevel !== null) {
res.push({
start: lastIndex,
end: index,
attributes: { bidiLevel: lastLevel },
});
}

lastIndex = index;
lastLevel = level;
}

index += 1;
}

if (lastIndex < string.length) {
res.push({
start: lastIndex,
end: string.length,
attributes: { bidiLevel: lastLevel },
});
}

return { string, runs: res };
};

export default bidiEngine;
6 changes: 5 additions & 1 deletion packages/textkit/src/glyph/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ const slice = (start, end, font, glyph) => {

const codePoints = glyph.codePoints.slice(start, end);
const string = String.fromCodePoint(...codePoints);
return font ? font.layout(string).glyphs : [glyph];

// passing LTR To force fontkit to not reverse the string
return font
? font.layout(string, undefined, undefined, undefined, 'ltr').glyphs
: [glyph];
};

export default slice;
2 changes: 2 additions & 0 deletions packages/textkit/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import layoutEngine from './layout';
import bidi from './engines/bidi';
import linebreaker from './engines/linebreaker';
import justification from './engines/justification';
import textDecoration from './engines/textDecoration';
Expand All @@ -7,6 +8,7 @@ import wordHyphenation from './engines/wordHyphenation';
import fontSubstitution from './engines/fontSubstitution';

export {
bidi,
linebreaker,
justification,
textDecoration,
Expand Down
4 changes: 2 additions & 2 deletions packages/textkit/src/layout/applyDefaultStyles.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
*/
const applyAttributes = (a) => {
return {
align: a.align || 'left',
align: a.align || (a.direction === 'rtl' ? 'right' : 'left'),
alignLastLine:
a.alignLastLine || (a.align === 'justify' ? 'left' : a.align || 'left'),
attachment: a.attachment || null,
backgroundColor: a.backgroundColor || null,
bidiLevel: a.bidiLevel || null,
bullet: a.bullet || null,
characterSpacing: a.characterSpacing || 0,
color: a.color || 'black',
direction: a.direction || 'ltr',
features: a.features || [],
fill: a.fill !== false,
font: a.font || null,
Expand Down
38 changes: 38 additions & 0 deletions packages/textkit/src/layout/bidiMirroring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import bidiFactory from 'bidi-js';
import { repeat } from '@react-pdf/fns';

const bidi = bidiFactory();

const getBidiLevels = (runs) => {
return runs.reduce((acc, run) => {
const length = run.end - run.start;
const levels = repeat(run.attributes.bidiLevel, length);
return acc.concat(levels);
}, []);
};

/**
* Perform bidi mirroring
*/
const mirrorString = () => {
/**
* @param {AttributedString} attributedString attributed string
* @returns {AttributedString} attributed string
*/
return (attributedString) => {
const levels = getBidiLevels(attributedString.runs);

let updatedString = '';
attributedString.string.split('').forEach((char, index) => {
const isRTL = levels[index] % 2 === 1;
const mirroredChar = isRTL
? bidi.getMirroredCharacter(attributedString.string.charAt(index))
: null;
updatedString += mirroredChar || char;
});

return { ...attributedString, string: updatedString, levels };
};
};

export default mirrorString;
Loading

0 comments on commit 8350154

Please sign in to comment.