diff --git a/.changeset/soft-camels-visit.md b/.changeset/soft-camels-visit.md
new file mode 100644
index 000000000..e36ed220f
--- /dev/null
+++ b/.changeset/soft-camels-visit.md
@@ -0,0 +1,6 @@
+---
+"@react-pdf/textkit": minor
+"@react-pdf/layout": minor
+---
+
+refactor: unify font substitution engines
diff --git a/packages/layout/src/svg/layoutText.ts b/packages/layout/src/svg/layoutText.ts
index a5790a6be..b96197429 100644
--- a/packages/layout/src/svg/layoutText.ts
+++ b/packages/layout/src/svg/layoutText.ts
@@ -6,6 +6,7 @@ import layoutEngine, {
   justification,
   scriptItemizer,
   wordHyphenation,
+  fontSubstitution,
   textDecoration,
   fromFragments,
   Fragment,
@@ -13,7 +14,6 @@ import layoutEngine, {
 } from '@react-pdf/textkit';
 
 import transformText from '../text/transformText';
-import fontSubstitution from '../text/fontSubstitution';
 import {
   SafeNode,
   SafeTextNode,
diff --git a/packages/layout/src/text/fontSubstitution.ts b/packages/layout/src/text/fontSubstitution.ts
deleted file mode 100644
index ba4a082b4..000000000
--- a/packages/layout/src/text/fontSubstitution.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { last } from '@react-pdf/fns';
-import { AttributedString, Run } from '@react-pdf/textkit';
-
-const IGNORED_CODE_POINTS = [173];
-
-const getFontSize = (run: Run) => run.attributes.fontSize || 12;
-
-const pickFontFromFontStack = (codePoint, fontStack, lastFont) => {
-  const fontStackWithFallback = [...fontStack, lastFont];
-  for (let i = 0; i < fontStackWithFallback.length; i += 1) {
-    const font = fontStackWithFallback[i];
-    if (
-      !IGNORED_CODE_POINTS.includes(codePoint) &&
-      font &&
-      font.hasGlyphForCodePoint &&
-      font.hasGlyphForCodePoint(codePoint)
-    ) {
-      return font;
-    }
-  }
-  return fontStack.at(-1);
-};
-
-const fontSubstitution =
-  () =>
-  ({ string, runs }: AttributedString) => {
-    let lastFont = null;
-    let lastFontSize = null;
-    let lastIndex = 0;
-    let index = 0;
-
-    const res: Run[] = [];
-
-    for (let i = 0; i < runs.length; i += 1) {
-      const run = runs[i];
-
-      if (string.length === 0) {
-        res.push({
-          start: 0,
-          end: 0,
-          attributes: { font: run.attributes.font },
-        });
-        break;
-      }
-
-      const chars = string.slice(run.start, run.end);
-
-      for (let j = 0; j < chars.length; j += 1) {
-        const char = chars[j];
-        const codePoint = char.codePointAt(0);
-        // If the default font does not have a glyph and the fallback font does, we use it
-        const font = pickFontFromFontStack(
-          codePoint,
-          run.attributes.font,
-          lastFont,
-        );
-
-        const fontSize = getFontSize(run);
-
-        // If anything that would impact res has changed, update it
-        if (
-          font !== lastFont ||
-          fontSize !== lastFontSize ||
-          font.unitsPerEm !== lastFont.unitsPerEm
-        ) {
-          if (lastFont) {
-            res.push({
-              start: lastIndex,
-              end: index,
-              attributes: {
-                font: lastFont,
-                scale: lastFontSize / lastFont.unitsPerEm,
-              },
-            });
-          }
-
-          lastFont = font;
-          lastFontSize = fontSize;
-          lastIndex = index;
-        }
-
-        index += char.length;
-      }
-    }
-
-    if (lastIndex < string.length) {
-      const fontSize = getFontSize(last(runs));
-
-      res.push({
-        start: lastIndex,
-        end: string.length,
-        attributes: {
-          font: lastFont,
-          scale: fontSize / lastFont.unitsPerEm,
-        },
-      });
-    }
-
-    return { string, runs: res } as AttributedString;
-  };
-
-export default fontSubstitution;
diff --git a/packages/layout/src/text/layoutText.ts b/packages/layout/src/text/layoutText.ts
index 9cf17f456..e87235b67 100644
--- a/packages/layout/src/text/layoutText.ts
+++ b/packages/layout/src/text/layoutText.ts
@@ -5,10 +5,10 @@ import layoutEngine, {
   scriptItemizer,
   wordHyphenation,
   textDecoration,
+  fontSubstitution,
 } from '@react-pdf/textkit';
 import FontStore from '@react-pdf/font';
 
-import fontSubstitution from './fontSubstitution';
 import getAttributedString from './getAttributedString';
 import { SafeTextNode } from '../types';
 
diff --git a/packages/layout/tests/text/fontSubstitution.test.ts b/packages/layout/tests/text/fontSubstitution.test.ts
deleted file mode 100644
index 46c425b03..000000000
--- a/packages/layout/tests/text/fontSubstitution.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { describe, expect, test } from 'vitest';
-import fontSubstitution from '../../src/text/fontSubstitution';
-import FontStore from '@react-pdf/font';
-
-const instance = fontSubstitution();
-
-const fontStore = new FontStore();
-
-describe('FontSubstitution', () => {
-  test('should return empty array if no runs passed', () => {
-    const string = instance({ string: '', runs: [] });
-
-    expect(string).toHaveProperty('runs', []);
-    expect(string).toHaveProperty('string', '');
-  });
-
-  test('should merge consecutive runs with same font', () => {
-    const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
-
-    const run1 = {
-      start: 0,
-      end: 3,
-      attributes: { font: [helvetica] },
-    } as any;
-
-    const run2 = {
-      start: 3,
-      end: 5,
-      attributes: { font: [helvetica] },
-    } as any;
-
-    const string = instance({ string: 'Lorem', runs: [run1, run2] });
-
-    expect(string).toHaveProperty('string', 'Lorem');
-    expect(string.runs).toHaveLength(1);
-    expect(string.runs[0]).toHaveProperty('start', 0);
-    expect(string.runs[0]).toHaveProperty('end', 5);
-    expect(string.runs[0].attributes.font).toBeTruthy();
-  });
-
-  test('should substitute many runs', () => {
-    const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
-    const helveticaBold = fontStore.getFont({
-      fontFamily: 'Helvetica',
-      fontWeight: 700,
-    }).data;
-
-    const run1 = {
-      start: 0,
-      end: 3,
-      attributes: { font: [helveticaBold] },
-    } as any;
-
-    const run2 = { start: 3, end: 5, attributes: { font: [helvetica] } } as any;
-
-    const string = instance({ string: 'Lorem', runs: [run1, run2] });
-
-    expect(string).toHaveProperty('string', 'Lorem');
-    expect(string.runs).toHaveLength(2);
-    expect(string.runs[0]).toHaveProperty('start', 0);
-    expect(string.runs[0]).toHaveProperty('end', 3);
-    expect(string.runs[0].attributes.font).toBeTruthy();
-    expect(string.runs[1]).toHaveProperty('start', 3);
-    expect(string.runs[1]).toHaveProperty('end', 5);
-    expect(string.runs[1].attributes.font).toBeTruthy();
-  });
-
-  describe('Fallback Font', () => {
-    const SimplifiedChineseFont = {
-      name: 'SimplifiedChineseFont',
-      hasGlyphForCodePoint: (codePoint) => codePoint === 20320,
-    };
-
-    test('should utilize a fallback font that supports the provided glyph', () => {
-      const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
-
-      const run = {
-        start: 0,
-        end: 1,
-        attributes: {
-          font: [helvetica, SimplifiedChineseFont],
-        },
-      } as any;
-
-      const string = instance({ string: '你', runs: [run] });
-
-      expect(string).toHaveProperty('string', '你');
-      expect(string.runs).toHaveLength(1);
-      expect(string.runs[0]).toHaveProperty('start', 0);
-      expect(string.runs[0]).toHaveProperty('end', 1);
-      expect(string.runs[0].attributes.font).toBe(SimplifiedChineseFont);
-    });
-
-    test('should split a run when fallback font is used on a portion of the run', () => {
-      const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
-
-      const run = {
-        start: 0,
-        end: 2,
-        attributes: {
-          font: [helvetica, SimplifiedChineseFont],
-        },
-      } as any;
-
-      const string = instance({ string: 'A你', runs: [run] });
-
-      expect(string).toHaveProperty('string', 'A你');
-      expect(string.runs).toHaveLength(2);
-      expect(string.runs[0]).toHaveProperty('start', 0);
-      expect(string.runs[0]).toHaveProperty('end', 1);
-      expect(string.runs[0].attributes.font).toBeTruthy();
-      expect(string.runs[1]).toHaveProperty('start', 1);
-      expect(string.runs[1]).toHaveProperty('end', 2);
-      expect(string.runs[1].attributes.font).toBe(SimplifiedChineseFont);
-    });
-  });
-});
diff --git a/packages/textkit/src/engines/fontSubstitution/index.ts b/packages/textkit/src/engines/fontSubstitution/index.ts
index dfe05c0e8..b840053c0 100644
--- a/packages/textkit/src/engines/fontSubstitution/index.ts
+++ b/packages/textkit/src/engines/fontSubstitution/index.ts
@@ -1,60 +1,81 @@
 import { last } from '@react-pdf/fns';
-
-import empty from '../../attributedString/empty';
 import { AttributedString, Run } from '../../types';
 
-/**
- * @param run - Run
- * @returns Font size
- */
-const getFontSize = (run: Run) => {
-  return run.attributes.fontSize || 12;
-};
+const IGNORED_CODE_POINTS = [173];
+
+const getFontSize = (run: Run) => run.attributes.fontSize || 12;
 
-/**
- * Resolve font runs in an AttributedString, grouping equal
- * runs and performing font substitution where necessary.
- */
-const fontSubstitution = () => {
-  /**
-   * @param attributedString - Attributed string
-   * @returns Attributed string
-   */
-  return (attributedString: AttributedString) => {
-    const { string, runs } = attributedString;
+const pickFontFromFontStack = (codePoint, fontStack, lastFont) => {
+  const fontStackWithFallback = [...fontStack, lastFont];
+  for (let i = 0; i < fontStackWithFallback.length; i += 1) {
+    const font = fontStackWithFallback[i];
+    if (
+      !IGNORED_CODE_POINTS.includes(codePoint) &&
+      font &&
+      font.hasGlyphForCodePoint &&
+      font.hasGlyphForCodePoint(codePoint)
+    ) {
+      return font;
+    }
+  }
+  return fontStack.at(-1);
+};
 
+const fontSubstitution =
+  () =>
+  ({ string, runs }: AttributedString) => {
     let lastFont = null;
+    let lastFontSize = null;
     let lastIndex = 0;
     let index = 0;
-    const res: Run[] = [];
 
-    if (!string) return empty();
+    const res: Run[] = [];
 
-    for (const run of runs) {
-      const fontSize = getFontSize(run);
-      const defaultFont = run.attributes.font;
+    for (let i = 0; i < runs.length; i += 1) {
+      const run = runs[i];
 
       if (string.length === 0) {
-        res.push({ start: 0, end: 0, attributes: { font: defaultFont } });
+        res.push({
+          start: 0,
+          end: 0,
+          attributes: { font: run.attributes.font },
+        });
         break;
       }
 
-      for (const char of string.slice(run.start, run.end)) {
-        const font = defaultFont;
+      const chars = string.slice(run.start, run.end);
+
+      for (let j = 0; j < chars.length; j += 1) {
+        const char = chars[j];
+        const codePoint = char.codePointAt(0);
+        // If the default font does not have a glyph and the fallback font does, we use it
+        const font = pickFontFromFontStack(
+          codePoint,
+          run.attributes.font,
+          lastFont,
+        );
 
-        if (font !== lastFont) {
+        const fontSize = getFontSize(run);
+
+        // If anything that would impact res has changed, update it
+        if (
+          font !== lastFont ||
+          fontSize !== lastFontSize ||
+          font.unitsPerEm !== lastFont.unitsPerEm
+        ) {
           if (lastFont) {
             res.push({
               start: lastIndex,
               end: index,
               attributes: {
                 font: lastFont,
-                scale: lastFont ? fontSize / lastFont.unitsPerEm : 0,
+                scale: lastFontSize / lastFont.unitsPerEm,
               },
             });
           }
 
           lastFont = font;
+          lastFontSize = fontSize;
           lastIndex = index;
         }
 
@@ -70,13 +91,12 @@ const fontSubstitution = () => {
         end: string.length,
         attributes: {
           font: lastFont,
-          scale: lastFont ? fontSize / lastFont.unitsPerEm : 0,
+          scale: fontSize / lastFont.unitsPerEm,
         },
       });
     }
 
-    return { string, runs: res };
+    return { string, runs: res } as AttributedString;
   };
-};
 
 export default fontSubstitution;
diff --git a/packages/textkit/tests/engines/fontSubstitution.test.ts b/packages/textkit/tests/engines/fontSubstitution.test.ts
index 5eaaf6ff9..90a0fea42 100644
--- a/packages/textkit/tests/engines/fontSubstitution.test.ts
+++ b/packages/textkit/tests/engines/fontSubstitution.test.ts
@@ -1,50 +1,119 @@
 import { describe, expect, test } from 'vitest';
 
-import empty from '../../src/attributedString/empty';
+import FontStore from '@react-pdf/font';
+
 import fontSubstitution from '../../src/engines/fontSubstitution';
 
 const instance = fontSubstitution();
 
+const fontStore = new FontStore();
+
 describe('FontSubstitution', () => {
   test('should return empty array if no runs passed', () => {
-    const string = instance(empty());
+    const string = instance({ string: '', runs: [] });
 
     expect(string).toHaveProperty('runs', []);
     expect(string).toHaveProperty('string', '');
   });
 
-  test('should return empty array for empty string', () => {
-    const run = { start: 0, end: 0, attributes: {} };
-    const string = instance({ string: '', runs: [run] });
+  test('should merge consecutive runs with same font', () => {
+    const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
 
-    expect(string).toHaveProperty('runs', []);
-    expect(string).toHaveProperty('string', '');
-  });
+    const run1 = {
+      start: 0,
+      end: 3,
+      attributes: { font: [helvetica] },
+    } as any;
+
+    const run2 = {
+      start: 3,
+      end: 5,
+      attributes: { font: [helvetica] },
+    } as any;
 
-  test('should merge consecutive runs with same font', () => {
-    const font = { ascent: 10, unitsPerEm: 2 };
-    const run1 = { start: 0, end: 3, attributes: { font } };
-    const run2 = { start: 3, end: 5, attributes: { font } };
     const string = instance({ string: 'Lorem', runs: [run1, run2] });
 
     expect(string).toHaveProperty('string', 'Lorem');
     expect(string.runs).toHaveLength(1);
     expect(string.runs[0]).toHaveProperty('start', 0);
     expect(string.runs[0]).toHaveProperty('end', 5);
+    expect(string.runs[0].attributes.font).toBeTruthy();
   });
 
   test('should substitute many runs', () => {
-    const font1 = { ascent: 10, unitsPerEm: 2 };
-    const font2 = { ascent: 8, unitsPerEm: 3 };
-    const run1 = { start: 0, end: 3, attributes: { font: font1 } };
-    const run2 = { start: 3, end: 5, attributes: { font: font2 } };
+    const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
+    const helveticaBold = fontStore.getFont({
+      fontFamily: 'Helvetica',
+      fontWeight: 700,
+    }).data;
+
+    const run1 = {
+      start: 0,
+      end: 3,
+      attributes: { font: [helveticaBold] },
+    } as any;
+
+    const run2 = { start: 3, end: 5, attributes: { font: [helvetica] } } as any;
+
     const string = instance({ string: 'Lorem', runs: [run1, run2] });
 
     expect(string).toHaveProperty('string', 'Lorem');
     expect(string.runs).toHaveLength(2);
     expect(string.runs[0]).toHaveProperty('start', 0);
     expect(string.runs[0]).toHaveProperty('end', 3);
+    expect(string.runs[0].attributes.font).toBeTruthy();
     expect(string.runs[1]).toHaveProperty('start', 3);
     expect(string.runs[1]).toHaveProperty('end', 5);
+    expect(string.runs[1].attributes.font).toBeTruthy();
+  });
+
+  describe('Fallback Font', () => {
+    const SimplifiedChineseFont = {
+      name: 'SimplifiedChineseFont',
+      hasGlyphForCodePoint: (codePoint) => codePoint === 20320,
+    };
+
+    test('should utilize a fallback font that supports the provided glyph', () => {
+      const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
+
+      const run = {
+        start: 0,
+        end: 1,
+        attributes: {
+          font: [helvetica, SimplifiedChineseFont],
+        },
+      } as any;
+
+      const string = instance({ string: '你', runs: [run] });
+
+      expect(string).toHaveProperty('string', '你');
+      expect(string.runs).toHaveLength(1);
+      expect(string.runs[0]).toHaveProperty('start', 0);
+      expect(string.runs[0]).toHaveProperty('end', 1);
+      expect(string.runs[0].attributes.font).toBe(SimplifiedChineseFont);
+    });
+
+    test('should split a run when fallback font is used on a portion of the run', () => {
+      const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;
+
+      const run = {
+        start: 0,
+        end: 2,
+        attributes: {
+          font: [helvetica, SimplifiedChineseFont],
+        },
+      } as any;
+
+      const string = instance({ string: 'A你', runs: [run] });
+
+      expect(string).toHaveProperty('string', 'A你');
+      expect(string.runs).toHaveLength(2);
+      expect(string.runs[0]).toHaveProperty('start', 0);
+      expect(string.runs[0]).toHaveProperty('end', 1);
+      expect(string.runs[0].attributes.font).toBeTruthy();
+      expect(string.runs[1]).toHaveProperty('start', 1);
+      expect(string.runs[1]).toHaveProperty('end', 2);
+      expect(string.runs[1].attributes.font).toBe(SimplifiedChineseFont);
+    });
   });
 });