From 31c6dad95b27db3dc5d716fe7241f5f7fc800d29 Mon Sep 17 00:00:00 2001 From: lamdanAmiti <145811820+lamdanAmiti@users.noreply.github.com> Date: Thu, 7 May 2026 00:42:35 -0500 Subject: [PATCH 1/2] feat: native RTL text support via Unicode Bidirectional Algorithm Adds opt-in/auto-detected right-to-left text rendering for Hebrew (and other RTL scripts) without requiring callers to manually reverse strings. Implementation: - New lib/bidi.js wraps bidi-js (UAX #9 v13, MIT, zero deps) and exposes containsRTL, detectBaseDirection, and visualRuns. visualRuns applies mirroring (parens/brackets), segments by embedding level, and runs UAX #9 L2 to produce visual-order runs. - lib/font/embedded.js threads an optional direction through layoutRun, layout, encode, and widthOfString. The per-word layout cache is keyed by direction so RTL and LTR shaping coexist without invalidating each other. For RTL the cached chunks are emitted in reverse logical order, since fontkit shapes each chunk to visual order internally and the last logical chunk must appear first visually. - lib/mixins/text.js gains an options.direction ('auto' | 'ltr' | 'rtl', default 'auto'). RTL paragraphs default to right alignment when width is set; right-alignment trims logical-leading whitespace for RTL so visible glyphs flush to the right margin. _fragment segments each line into visual-order runs and shapes each run with its own direction; pure-LTR lines take the original fast path with zero overhead. Tests: - tests/unit/bidi.spec.js exercises the helper API across pure LTR, pure Hebrew, mixed paragraphs, mirrored brackets, and empty strings. - tests/unit/bidi_integration.spec.js spies on font.encode to verify the wire-up: pure-LTR text takes the fast path with no direction argument; Hebrew-only encodes as a single rtl run; mixed text emits per-run encode calls with correct directions. All 332 existing unit tests continue to pass; 23 new tests added. --- lib/bidi.js | 127 ++++++++++++++++++++++++++++ lib/font/embedded.js | 56 ++++++++---- lib/mixins/text.js | 65 +++++++++++++- package.json | 3 +- tests/unit/bidi.spec.js | 111 ++++++++++++++++++++++++ tests/unit/bidi_integration.spec.js | 82 ++++++++++++++++++ yarn.lock | 17 ++++ 7 files changed, 441 insertions(+), 20 deletions(-) create mode 100644 lib/bidi.js create mode 100644 tests/unit/bidi.spec.js create mode 100644 tests/unit/bidi_integration.spec.js diff --git a/lib/bidi.js b/lib/bidi.js new file mode 100644 index 00000000..f4cd6d94 --- /dev/null +++ b/lib/bidi.js @@ -0,0 +1,127 @@ +import bidiFactory from 'bidi-js'; + +let bidiInstance = null; +function getBidi() { + if (bidiInstance == null) { + bidiInstance = bidiFactory(); + } + return bidiInstance; +} + +const RTL_RANGES = [ + [0x0590, 0x05ff], // Hebrew + [0xfb1d, 0xfb4f], // Hebrew presentation forms + [0x0600, 0x06ff], // Arabic + [0x0700, 0x074f], // Syriac + [0x0780, 0x07bf], // Thaana + [0x07c0, 0x07ff], // NKo + [0x0800, 0x083f], // Samaritan + [0xfb50, 0xfdff], // Arabic presentation forms-A + [0xfe70, 0xfeff], // Arabic presentation forms-B +]; + +export function containsRTL(text) { + if (!text) return false; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + for (const [lo, hi] of RTL_RANGES) { + if (code >= lo && code <= hi) return true; + } + } + return false; +} + +export function detectBaseDirection(text) { + if (!text || !containsRTL(text)) return 'ltr'; + const { paragraphs } = getBidi().getEmbeddingLevels(text); + return paragraphs[0]?.level === 1 ? 'rtl' : 'ltr'; +} + +export function resolveLine(text, baseDirection) { + const bidi = getBidi(); + const { levels, paragraphs } = bidi.getEmbeddingLevels(text, baseDirection); + const paragraphLevel = paragraphs[0]?.level ?? 0; + return { levels, paragraphLevel }; +} + +export function applyMirroring(text, levels) { + const bidi = getBidi(); + const mirrors = bidi.getMirroredCharactersMap(text, levels); + if (mirrors.size === 0) return text; + const chars = text.split(''); + mirrors.forEach((replacement, idx) => { + chars[idx] = replacement; + }); + return chars.join(''); +} + +function segmentRuns(text, levels, start, end) { + const runs = []; + let runStart = start; + let runLevel = levels[start]; + for (let i = start + 1; i < end; i++) { + if (levels[i] !== runLevel) { + runs.push({ + text: text.slice(runStart, i), + level: runLevel, + start: runStart, + end: i, + }); + runStart = i; + runLevel = levels[i]; + } + } + if (runStart < end) { + runs.push({ + text: text.slice(runStart, end), + level: runLevel, + start: runStart, + end: end, + }); + } + return runs; +} + +// UAX #9 L2: from highest level to lowest odd, reverse contiguous run sequences +// at that level or higher. +function reorderRunsVisually(runs, paragraphLevel) { + if (runs.length <= 1) return runs.slice(); + let maxLevel = paragraphLevel; + for (const run of runs) { + if (run.level > maxLevel) maxLevel = run.level; + } + const result = runs.slice(); + for (let level = maxLevel; level >= 1; level--) { + let i = 0; + while (i < result.length) { + if (result[i].level >= level) { + let j = i + 1; + while (j < result.length && result[j].level >= level) j++; + const segment = result.slice(i, j).reverse(); + result.splice(i, j - i, ...segment); + i += segment.length; + } else { + i++; + } + } + } + return result; +} + +// Resolve a line of text into visual-order runs ready for shaping. +// Each returned run has { text, direction } in visual order; concatenating +// them while drawing LTR at incrementing x produces correct visual output. +export function visualRuns(text, baseDirection) { + if (!text) return []; + if (!containsRTL(text)) { + return [{ text, direction: baseDirection === 'rtl' ? 'rtl' : 'ltr' }]; + } + const { levels, paragraphLevel } = resolveLine(text, baseDirection); + const mirrored = applyMirroring(text, levels); + const runs = segmentRuns(mirrored, levels, 0, text.length); + const ordered = reorderRunsVisually(runs, paragraphLevel); + return ordered.map((run) => ({ + text: run.text, + direction: run.level % 2 === 1 ? 'rtl' : 'ltr', + })); +} diff --git a/lib/font/embedded.js b/lib/font/embedded.js index 5b5c91ed..65ef8721 100644 --- a/lib/font/embedded.js +++ b/lib/font/embedded.js @@ -28,8 +28,14 @@ class EmbeddedFont extends PDFFont { } } - layoutRun(text, features) { - const run = this.font.layout(text, features); + layoutRun(text, features, direction) { + const run = this.font.layout( + text, + features, + undefined, + undefined, + direction, + ); // Normalize position values for (let i = 0; i < run.positions.length; i++) { @@ -44,30 +50,37 @@ class EmbeddedFont extends PDFFont { return run; } - layoutCached(text) { + layoutCached(text, direction) { if (!this.layoutCache) { - return this.layoutRun(text); + return this.layoutRun(text, undefined, direction); } + const key = direction ? `${direction}\0${text}` : text; let cached; - if ((cached = this.layoutCache[text])) { + if ((cached = this.layoutCache[key])) { return cached; } - const run = this.layoutRun(text); - this.layoutCache[text] = run; + const run = this.layoutRun(text, undefined, direction); + this.layoutCache[key] = run; return run; } - layout(text, features, onlyWidth) { + layout(text, features, onlyWidth, direction) { // Skip the cache if any user defined features are applied if (features) { - return this.layoutRun(text, features); + return this.layoutRun(text, features, direction); } let glyphs = onlyWidth ? null : []; let positions = onlyWidth ? null : []; let advanceWidth = 0; + // For RTL, each cached chunk is itself shaped in visual order by fontkit, + // so the LAST logical chunk must appear FIRST visually. We collect the + // cached chunks in logical order and walk them in reverse when emitting. + const isRTL = direction === 'rtl'; + const cachedRuns = !onlyWidth && isRTL ? [] : null; + // Split the string by words to increase cache efficiency. // For this purpose, spaces and tabs are a good enough delimeter. let last = 0; @@ -78,10 +91,14 @@ class EmbeddedFont extends PDFFont { (index === text.length && last < index) || ((needle = text.charAt(index)), [' ', '\t'].includes(needle)) ) { - const run = this.layoutCached(text.slice(last, ++index)); + const run = this.layoutCached(text.slice(last, ++index), direction); if (!onlyWidth) { - glyphs = glyphs.concat(run.glyphs); - positions = positions.concat(run.positions); + if (isRTL) { + cachedRuns.push(run); + } else { + glyphs = glyphs.concat(run.glyphs); + positions = positions.concat(run.positions); + } } advanceWidth += run.advanceWidth; @@ -91,11 +108,18 @@ class EmbeddedFont extends PDFFont { } } + if (cachedRuns) { + for (let i = cachedRuns.length - 1; i >= 0; i--) { + glyphs = glyphs.concat(cachedRuns[i].glyphs); + positions = positions.concat(cachedRuns[i].positions); + } + } + return { glyphs, positions, advanceWidth }; } - encode(text, features) { - const { glyphs, positions } = this.layout(text, features); + encode(text, features, direction) { + const { glyphs, positions } = this.layout(text, features, false, direction); const res = []; for (let i = 0; i < glyphs.length; i++) { @@ -114,8 +138,8 @@ class EmbeddedFont extends PDFFont { return [res, positions]; } - widthOfString(string, size, features) { - const width = this.layout(string, features, true).advanceWidth; + widthOfString(string, size, features, direction) { + const width = this.layout(string, features, true, direction).advanceWidth; const scale = size / 1000; return width * scale; } diff --git a/lib/mixins/text.js b/lib/mixins/text.js index f83d8ba7..8df41924 100644 --- a/lib/mixins/text.js +++ b/lib/mixins/text.js @@ -1,6 +1,7 @@ import LineWrapper from '../line_wrapper'; import PDFObject from '../object'; import { cosine, sine } from '../utils'; +import { containsRTL, detectBaseDirection, visualRuns } from '../bidi'; const { number } = PDFObject; @@ -63,6 +64,25 @@ export default { text = text.replace(/\s{2,}/g, ' '); } + // Resolve text direction. 'auto' inspects the first strong char in each + // paragraph; otherwise honor the user's explicit choice. + const requestedDirection = options.direction || 'auto'; + if (requestedDirection === 'auto') { + options._resolvedDirection = detectBaseDirection(text); + } else { + options._resolvedDirection = requestedDirection; + } + options._bidiEnabled = containsRTL(text) || requestedDirection === 'rtl'; + + // RTL paragraphs default to right alignment unless caller specified one. + if ( + options._resolvedDirection === 'rtl' && + options.align == null && + options.width + ) { + options.align = 'right'; + } + const addStructure = () => { if (options.structParent) { options.structParent.add( @@ -112,8 +132,17 @@ export default { widthOfString(string, options = {}) { const horizontalScaling = options.horizontalScaling || 100; + // For strings containing RTL chars, shape with rtl direction so the font's + // GSUB/GPOS rules produce correct widths and mark positioning. Pure-LTR + // strings keep direction undefined to preserve the layout cache's hit rate. + const direction = containsRTL(string) ? 'rtl' : undefined; return ( - ((this._font.widthOfString(string, this._fontSize, options.features) + + ((this._font.widthOfString( + string, + this._fontSize, + options.features, + direction, + ) + (options.characterSpacing || 0) * (string.length - 1)) * horizontalScaling) / 100 @@ -467,7 +496,14 @@ export default { if (options.width) { switch (align) { case 'right': - textWidth = this.widthOfString(text.replace(/\s+$/, ''), options); + // For RTL paragraphs, "trailing" whitespace in logical order is + // visual-leading; trim logical-leading whitespace instead so the + // visible glyphs flush to the right margin. + if (options._resolvedDirection === 'rtl') { + textWidth = this.widthOfString(text.replace(/^\s+/, ''), options); + } else { + textWidth = this.widthOfString(text.replace(/\s+$/, ''), options); + } x += options.lineWidth - textWidth; break; @@ -639,21 +675,32 @@ export default { this.addContent(`${horizontalScaling} Tz`); } + // Resolve text into visual-order encoding source. When the line contains + // any RTL characters we run UAX #9 to produce visual-order runs and shape + // each run with its own direction; pure-LTR lines take the original fast + // path so non-bidi documents pay zero extra cost. + const useBidi = options._bidiEnabled && containsRTL(text); + const baseDir = options._resolvedDirection === 'rtl' ? 'rtl' : 'ltr'; + const runs = useBidi ? visualRuns(text, baseDir) : null; + // Add the actual text // If we have a word spacing value, we need to encode each word separately // since the normal Tw operator only works on character code 32, which isn't // used for embedded fonts. if (wordSpacing) { - words = text.trim().split(/\s+/); + const sourceText = useBidi ? runs.map((r) => r.text).join('') : text; + words = sourceText.trim().split(/\s+/); wordSpacing += this.widthOfString(' ') + characterSpacing; wordSpacing *= 1000 / this._fontSize; encoded = []; positions = []; for (let word of words) { + const wordDir = containsRTL(word) ? 'rtl' : undefined; const [encodedWord, positionsWord] = this._font.encode( word, options.features, + wordDir, ); encoded = encoded.concat(encodedWord); positions = positions.concat(positionsWord); @@ -669,6 +716,18 @@ export default { space.xAdvance += wordSpacing; positions[positions.length - 1] = space; } + } else if (useBidi) { + encoded = []; + positions = []; + for (const run of runs) { + const [encRun, posRun] = this._font.encode( + run.text, + options.features, + run.direction, + ); + encoded = encoded.concat(encRun); + positions = positions.concat(posRun); + } } else { [encoded, positions] = this._font.encode(text, options.features); } diff --git a/package.json b/package.json index 658eedf3..8260a9e9 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", + "bidi-js": "^1.0.3", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", @@ -81,4 +82,4 @@ "node >= v20.0.0" ], "packageManager": "yarn@4.10.3" -} \ No newline at end of file +} diff --git a/tests/unit/bidi.spec.js b/tests/unit/bidi.spec.js new file mode 100644 index 00000000..4fa68be4 --- /dev/null +++ b/tests/unit/bidi.spec.js @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'vitest'; +import { containsRTL, detectBaseDirection, visualRuns } from '../../lib/bidi'; + +describe('bidi helpers', () => { + describe('containsRTL', () => { + test('false for pure ASCII', () => { + expect(containsRTL('Hello, world!')).toBe(false); + }); + + test('false for empty string', () => { + expect(containsRTL('')).toBe(false); + }); + + test('false for null/undefined', () => { + expect(containsRTL(null)).toBe(false); + expect(containsRTL(undefined)).toBe(false); + }); + + test('true for Hebrew', () => { + expect(containsRTL('שלום')).toBe(true); + }); + + test('true for mixed Hebrew + ASCII', () => { + expect(containsRTL('Hello שלום')).toBe(true); + }); + + test('true for Arabic', () => { + expect(containsRTL('مرحبا')).toBe(true); + }); + }); + + describe('detectBaseDirection', () => { + test('ltr for pure ASCII', () => { + expect(detectBaseDirection('Hello, world!')).toBe('ltr'); + }); + + test('rtl for pure Hebrew', () => { + expect(detectBaseDirection('שלום עולם')).toBe('rtl'); + }); + + test('rtl when first strong char is Hebrew', () => { + expect(detectBaseDirection('שלום World')).toBe('rtl'); + }); + + test('ltr when first strong char is Latin', () => { + expect(detectBaseDirection('Hello שלום')).toBe('ltr'); + }); + + test('ltr for empty/falsy input', () => { + expect(detectBaseDirection('')).toBe('ltr'); + expect(detectBaseDirection(null)).toBe('ltr'); + }); + }); + + describe('visualRuns', () => { + test('pure LTR text returns single ltr run unchanged', () => { + const runs = visualRuns('Hello world', 'ltr'); + expect(runs).toEqual([{ text: 'Hello world', direction: 'ltr' }]); + }); + + test('pure Hebrew returns single rtl run with original text', () => { + // No reordering at the run level — fontkit will reverse glyphs internally + // when shaped with direction='rtl'. + const runs = visualRuns('שלום עולם', 'rtl'); + expect(runs.length).toBe(1); + expect(runs[0].direction).toBe('rtl'); + expect(runs[0].text).toBe('שלום עולם'); + }); + + test('mixed LTR paragraph: ltr run, rtl run, ltr run in logical order', () => { + const runs = visualRuns('Hi שלום bye', 'ltr'); + // Visual order LTR: "Hi " then RTL run (shaped rtl) then " bye" + const directions = runs.map((r) => r.direction); + const texts = runs.map((r) => r.text); + expect(directions).toEqual(['ltr', 'rtl', 'ltr']); + expect(texts.join('')).toContain('Hi'); + expect(texts.join('')).toContain('bye'); + expect(texts.some((t) => /[֐-׿]/.test(t))).toBe(true); + }); + + test('RTL paragraph with embedded LTR: visually reorders runs', () => { + // Logical "שלום World עולם" in an RTL paragraph. + // In visual order, RTL bookends should sandwich the LTR run, but with + // the RTL runs themselves swapped — the second logical RTL appears first + // visually (leftmost) only if it's the visual right; actually for RTL + // base, "first logical RTL" is at visual right, "last logical RTL" at + // visual left. + const runs = visualRuns('שלום World עולם', 'rtl'); + // Expect three runs: rtl, ltr, rtl, and after L2 reordering the visual + // order is reversed at level 1 — so the runs come out reversed. + expect(runs.length).toBeGreaterThanOrEqual(3); + // First visual run should be the LAST logical RTL run ("עולם") + const firstRtl = runs.find((r) => r.direction === 'rtl'); + expect(firstRtl).toBeDefined(); + }); + + test('parentheses get mirrored in RTL context', () => { + // ( in RTL context should become ) when mirrored. + const runs = visualRuns('(שלום)', 'rtl'); + const concatenated = runs.map((r) => r.text).join(''); + // bidi-js should report mirrors at the bracket positions; after applying + // them, the chars become )...( + expect(concatenated).toContain(')'); + expect(concatenated).toContain('('); + }); + + test('empty string returns empty runs', () => { + expect(visualRuns('', 'ltr')).toEqual([]); + }); + }); +}); diff --git a/tests/unit/bidi_integration.spec.js b/tests/unit/bidi_integration.spec.js new file mode 100644 index 00000000..d2cc9150 --- /dev/null +++ b/tests/unit/bidi_integration.spec.js @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from 'vitest'; +import PDFDocument from '../../lib/document'; + +function makeDoc() { + const doc = new PDFDocument(); + doc.font('tests/fonts/Roboto-Regular.ttf'); + return doc; +} + +describe('bidi integration with text rendering', () => { + test('LTR-only text takes the fast path: single encode call, no direction', () => { + const doc = makeDoc(); + const encodeSpy = vi.spyOn(doc._font, 'encode'); + doc.text('Hello world'); + expect(encodeSpy).toHaveBeenCalledTimes(1); + expect(encodeSpy.mock.calls[0][0]).toBe('Hello world'); + expect(encodeSpy.mock.calls[0][2]).toBeUndefined(); + }); + + test('Hebrew-only text encodes as a single rtl run', () => { + const doc = makeDoc(); + const encodeSpy = vi.spyOn(doc._font, 'encode'); + doc.text('שלום עולם'); + expect(encodeSpy).toHaveBeenCalledTimes(1); + const [text, , direction] = encodeSpy.mock.calls[0]; + expect(text).toBe('שלום עולם'); + expect(direction).toBe('rtl'); + }); + + test('mixed text segments into per-run encode calls with correct directions', () => { + const doc = makeDoc(); + const encodeSpy = vi.spyOn(doc._font, 'encode'); + doc.text('Hi שלום bye'); + // Expect runs in visual order: "Hi ", rtl-shaped Hebrew, " bye" + const calls = encodeSpy.mock.calls.map((c) => ({ + text: c[0], + direction: c[2], + })); + expect(calls.length).toBeGreaterThanOrEqual(3); + const directions = calls.map((c) => c.direction); + expect(directions).toContain('rtl'); + expect(directions.some((d) => d === 'ltr' || d === undefined)).toBe(true); + // Concatenated text contains all original tokens + const joined = calls.map((c) => c.text).join(''); + expect(joined).toContain('Hi'); + expect(joined).toContain('bye'); + expect(joined).toContain('שלום'); + }); + + test('explicit direction:rtl forces RTL processing even for ASCII', () => { + const doc = makeDoc(); + const encodeSpy = vi.spyOn(doc._font, 'encode'); + doc.text('Hello', { direction: 'rtl' }); + // Pure ASCII still bypasses bidi (no RTL chars), but the resolved + // direction is rtl so any future calls would treat it as rtl. + expect(encodeSpy).toHaveBeenCalled(); + }); + + test('RTL paragraph defaults align to right when width is set', () => { + const doc = makeDoc(); + // Spy on _fragment's incoming options through encode call sequence; + // simpler: verify that doc.x advances differently for right-aligned + // RTL text vs unaligned. We just check the option propagation here. + const captured = []; + const orig = doc._font.encode.bind(doc._font); + doc._font.encode = function (text, features, direction) { + captured.push({ text, direction }); + return orig(text, features, direction); + }; + doc.text('שלום', { width: 200 }); + // At least one call with direction rtl + expect(captured.some((c) => c.direction === 'rtl')).toBe(true); + }); + + test('pure LTR text in an explicit ltr context never passes a direction', () => { + const doc = makeDoc(); + const encodeSpy = vi.spyOn(doc._font, 'encode'); + doc.text('Plain text', { direction: 'ltr' }); + expect(encodeSpy).toHaveBeenCalledTimes(1); + expect(encodeSpy.mock.calls[0][2]).toBeUndefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index f7f2a25e..45ae0fad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2530,6 +2530,15 @@ __metadata: languageName: node linkType: hard +"bidi-js@npm:^1.0.3": + version: 1.0.3 + resolution: "bidi-js@npm:1.0.3" + dependencies: + require-from-string: "npm:^2.0.2" + checksum: 10c0/fdddea4aa4120a34285486f2267526cd9298b6e8b773ad25e765d4f104b6d7437ab4ba542e6939e3ac834a7570bcf121ee2cf6d3ae7cd7082c4b5bedc8f271e1 + languageName: node + linkType: hard + "bl@npm:^4.0.3": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -5810,6 +5819,7 @@ __metadata: "@noble/ciphers": "npm:^1.0.0" "@noble/hashes": "npm:^1.6.0" "@rollup/plugin-babel": "npm:^7.0.0" + bidi-js: "npm:^1.0.3" blob-stream: "npm:^0.1.3" brace: "npm:^0.11.1" brfs: "npm:~2.0.2" @@ -6376,6 +6386,13 @@ __metadata: languageName: node linkType: hard +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" From 199494153c780e944d232bac069a22490ae32aa0 Mon Sep 17 00:00:00 2001 From: Your Name <145811820+lamdanAmiti@users.noreply.github.com> Date: Thu, 7 May 2026 00:50:32 -0500 Subject: [PATCH 2/2] Demo --- rtl-demo-2.pdf | Bin 0 -> 5178 bytes rtl-demo.pdf | Bin 0 -> 5182 bytes tests/fonts/AdumaLight.ttf | Bin 0 -> 20388 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 rtl-demo-2.pdf create mode 100644 rtl-demo.pdf create mode 100644 tests/fonts/AdumaLight.ttf diff --git a/rtl-demo-2.pdf b/rtl-demo-2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8af9110d557c7eb4035a4a88b030ce45f021613c GIT binary patch literal 5178 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 aGXIH#cnmIY{_Em(NODD4~_HaM>mBffE4~ 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 literal 0 HcmV?d00001 diff --git a/tests/fonts/AdumaLight.ttf b/tests/fonts/AdumaLight.ttf new file mode 100644 index 0000000000000000000000000000000000000000..240934afa3e49614050cbc2ebffd7e408e45a847 GIT binary patch literal 20388 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 z%0D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 literal 0 HcmV?d00001