Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit bf39f7038ecff318c806b079c3beab529d476077
truex <truex@equwal.com>
2026-09-26 14:30:46 -0700

Turn pages with keys and swipes, and go to a page

Only the Prev and Next buttons turned the page, so page 30 of 40 took
29 presses. The new module nav.ts adds these ways:

- ArrowRight, ArrowLeft, PageDown and PageUp turn the page, but not
  while the focus is in a field.
- A horizontal swipe on the viewer of at least 50 CSS pixels, with
  |dx| more than 1.5 |dy|, turns the page. A zoomed page or a page wider
  than the viewer does not turn. The click after a swipe does no lookup.
- The page label opens a field for a page number. Enter goes, Escape
  or a tap outside cancels.

The new setting "Pages turn right to left (vertical Japanese)" is on
for the jpn_vert OCR language until the user sets it. Then ArrowLeft,
a swipe to the right and the left arrow go to the next page. The pure
module paging.ts holds the decisions.

 index.html          |  13 ++++
 src/main.ts         |  49 ++++++++----
 src/nav.ts          | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/paging.ts       |  86 +++++++++++++++++++++
 src/style.css       |  20 +++++
 test/paging.test.ts | 149 +++++++++++++++++++++++++++++++++++++
 6 files changed, 513 insertions(+), 14 deletions(-)
diff --git a/index.html b/index.html
index 79218ee..fd3affe 100644
--- a/index.html
+++ b/index.html
@@ -22,6 +22,16 @@
         <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" /></svg>
       </button>
       <button id="page-label" title="Go to a page" disabled>No PDF</button>
+      <input
+        id="page-input"
+        type="text"
+        inputmode="numeric"
+        enterkeyhint="go"
+        autocomplete="off"
+        aria-label="Go to page"
+        title="Type a page number. Enter goes, Escape cancels."
+        hidden
+      />
       <button id="page-right" class="icon" aria-label="Next page" title="Next page" disabled>
         <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7" /></svg>
       </button>
@@ -106,6 +116,9 @@
           <option value="jpn+eng">Japanese + English</option>
         </select>
       </label>
+      <label class="check"
+        ><input id="rtl" type="checkbox" /> Pages turn right to left (vertical Japanese)</label
+      >
       <label class="check"
         ><input id="force-ocr" type="checkbox" /> Force OCR (the text layer of the PDF is
         wrong)</label
diff --git a/src/main.ts b/src/main.ts
index 71b3f64..e0d6397 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -17,7 +17,9 @@ import {
   playerMessage,
   readingText,
 } from './messages';
+import { setupNav } from './nav';
 import { createOcr, type Ocr } from './ocr';
+import { defaultRtl } from './paging';
 import { loadPdf, type PdfDoc } from './pdf';
 import { nextPage } from './read-order';
 import { say, showPlayer, showReading } from './status';
@@ -78,6 +80,8 @@ const ui = {
   play: el<HTMLButtonElement>('play'),
   syncPage: el<HTMLButtonElement>('sync-page'),
   pageLabel: el<HTMLButtonElement>('page-label'),
+  pageInput: el<HTMLInputElement>('page-input'),
+  rtl: el<HTMLInputElement>('rtl'),
   viewer: el<HTMLDivElement>('viewer'),
   empty: el<HTMLElement>('empty'),
   openPdf: el<HTMLButtonElement>('open-pdf'),
@@ -113,6 +117,19 @@ const state = {
 
 const view = createPageView(ui, marks);
 
+const desktop = window.matchMedia('(min-width: 900px)');
+
+const nav = setupNav(
+  ui,
+  {
+    pages: () => state.pdf?.numPages ?? 0,
+    current: () => state.currentPage,
+    rtl: () => ui.rtl.checked,
+    drawerOpen: () => !desktop.matches && document.body.classList.contains('menu-open'),
+  },
+  (index) => void showPage(index),
+);
+
 // --- Settings ---
 
 const settings = {
@@ -138,8 +155,24 @@ function loadSettings(): void {
   ui.subLang.value = settings.get('subLang', 'auto');
   ui.forceOcr.checked = settings.get('forceOcr', '0') === '1';
   ui.pauseLookup.checked = settings.get('pauseLookup', '1') === '1';
+  loadRtl();
+}
+
+/**
+ * The reading direction: the setting of the user, else the default for
+ * the OCR language, so that vertical Japanese turns right to left.
+ */
+function loadRtl(): void {
+  const saved = settings.get('rtl', '');
+  ui.rtl.checked = saved === '' ? defaultRtl(ui.lang.value) : saved === '1';
+  nav.update();
 }
 
+ui.rtl.addEventListener('change', () => {
+  settings.set('rtl', ui.rtl.checked ? '1' : '0');
+  nav.update();
+});
+
 ui.follow.addEventListener('change', () => {
   settings.set('follow', followMode());
   state.markedCue = -1;
@@ -152,6 +185,7 @@ ui.pauseLookup.addEventListener('change', () =>
 ui.subLang.addEventListener('change', () => settings.set('subLang', ui.subLang.value));
 ui.lang.addEventListener('change', () => {
   settings.set('lang', ui.lang.value);
+  loadRtl();
   void readBook();
 });
 ui.forceOcr.addEventListener('change', () => {
@@ -528,16 +562,6 @@ ui.audioFile.addEventListener('change', () => {
 
 // --- Page view ---
 
-/** Shows the page label, and enables the arrows that lead to a page. */
-function updateNav(): void {
-  const pdf = state.pdf;
-  const page = state.currentPage;
-  ui.pageLabel.disabled = !pdf;
-  ui.pageLabel.textContent = pdf ? `${page + 1} / ${pdf.numPages}` : 'No PDF';
-  ui.pageLeft.disabled = !pdf || page <= 0;
-  ui.pageRight.disabled = !pdf || page >= pdf.numPages - 1;
-}
-
 /** The mark of page `index`: one box per text line of the marked cue. */
 function marks(index: number): Marks | null {
   const entry = state.pages[index];
@@ -554,13 +578,10 @@ async function showPage(index: number): Promise<void> {
   const pdf = state.pdf;
   if (!pdf || index < 0 || index >= pdf.numPages) return;
   state.currentPage = index;
-  updateNav();
+  nav.update();
   await view.show(pdf, index);
 }
 
-ui.pageLeft.addEventListener('click', () => void showPage(state.currentPage - 1));
-ui.pageRight.addEventListener('click', () => void showPage(state.currentPage + 1));
-
 // --- Tap on a word ---
 
 /** Tap on the page: send the text under the tap to the dictionary. */
diff --git a/src/nav.ts b/src/nav.ts
new file mode 100644
index 0000000..0659027
--- /dev/null
+++ b/src/nav.ts
@@ -0,0 +1,210 @@
+// Page turns by the user: the arrows of the top bar, the keys, a swipe on
+// the viewer, and the page jump of the page label. The decisions are in
+// paging.ts.
+
+import { arrowsOff, arrowStep, keyStep, parsePage, swipeStep, type Step } from './paging';
+import { say } from './status';
+
+/** A click this soon after the end of a swipe comes from the swipe, and does no lookup. */
+const CLICK_AFTER_SWIPE_MS = 400;
+
+/** A click this soon after the tap that cancels the page jump comes from that tap. */
+const CLICK_AFTER_CANCEL_MS = 1000;
+
+/** A page zoomed in more than this is zoomed. */
+const ZOOMED = 1.01;
+
+export interface NavElements {
+  viewer: HTMLElement;
+  pageLeft: HTMLButtonElement;
+  pageRight: HTMLButtonElement;
+  pageLabel: HTMLButtonElement;
+  pageInput: HTMLInputElement;
+}
+
+export interface NavState {
+  /** The count of pages, or 0 with no PDF. */
+  pages(): number;
+  /** The index of the page shown. */
+  current(): number;
+  /** True when the pages turn right to left. */
+  rtl(): boolean;
+  /** True while the drawer covers the page. */
+  drawerOpen(): boolean;
+}
+
+export interface Nav {
+  /** Shows the page label, and the state and the names of the arrows. */
+  update(): void;
+}
+
+function zoomed(): boolean {
+  return (window.visualViewport?.scale ?? 1) > ZOOMED;
+}
+
+/** `turnTo(index)` shows the page that the user asked for. */
+export function setupNav(ui: NavElements, s: NavState, turnTo: (index: number) => void): Nav {
+  function go(step: Step): void {
+    const to = s.current() + step;
+    if (to >= 0 && to < s.pages()) turnTo(to);
+  }
+
+  // --- The arrows ---
+
+  ui.pageLeft.addEventListener('click', () => go(arrowStep('left', s.rtl())));
+  ui.pageRight.addEventListener('click', () => go(arrowStep('right', s.rtl())));
+
+  function name(button: HTMLButtonElement, step: Step): void {
+    const text = step > 0 ? 'Next page' : 'Previous page';
+    if (button.title === text) return;
+    button.title = text;
+    button.setAttribute('aria-label', text);
+  }
+
+  // --- The keys ---
+
+  document.addEventListener('keydown', (e) => {
+    if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || s.drawerOpen()) return;
+    const target = e.target;
+    if (
+      target instanceof HTMLElement &&
+      (target.closest('input, select, textarea') || target.isContentEditable)
+    ) {
+      return;
+    }
+    const step = keyStep(e.key, s.rtl());
+    if (step === 0) return;
+    e.preventDefault();
+    go(step);
+  });
+
+  // --- The swipe ---
+
+  // The browser pans the page only vertically, so a sideways move stays
+  // with the swipe. A zoomed page pans in each direction.
+  const markZoom = (): void => {
+    document.body.classList.toggle('zoomed', zoomed());
+  };
+  window.visualViewport?.addEventListener('resize', markZoom);
+  markZoom();
+
+  let start: { id: number; x: number; y: number } | null = null;
+  let swipedAt = -Infinity;
+  ui.viewer.addEventListener('pointerdown', (e) => {
+    // A second finger makes a pinch, not a swipe.
+    start = e.isPrimary ? { id: e.pointerId, x: e.clientX, y: e.clientY } : null;
+  });
+  ui.viewer.addEventListener('pointercancel', () => {
+    start = null;
+  });
+  ui.viewer.addEventListener('pointerup', (e) => {
+    if (!start || e.pointerId !== start.id) return;
+    const dx = e.clientX - start.x;
+    const dy = e.clientY - start.y;
+    start = null;
+    if (s.drawerOpen()) return;
+    const v = ui.viewer;
+    const step = swipeStep(
+      { dx, dy, zoomed: zoomed(), wide: v.scrollWidth > v.clientWidth + 1 },
+      s.rtl(),
+    );
+    if (step === 0) return;
+    swipedAt = e.timeStamp;
+    go(step);
+  });
+  // The click after a swipe is not a tap on a word.
+  ui.viewer.addEventListener(
+    'click',
+    (e) => {
+      if (e.timeStamp - swipedAt < CLICK_AFTER_SWIPE_MS) {
+        swipedAt = -Infinity;
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    },
+    true,
+  );
+
+  // --- The page jump ---
+
+  function openJump(): void {
+    if (s.pages() === 0) return;
+    ui.pageInput.value = String(s.current() + 1);
+    ui.pageInput.placeholder = `1-${s.pages()}`;
+    ui.pageLabel.hidden = true;
+    ui.pageInput.hidden = false;
+    ui.pageInput.focus();
+    ui.pageInput.select();
+  }
+
+  function closeJump(): void {
+    if (ui.pageInput.hidden) return;
+    ui.pageInput.hidden = true;
+    ui.pageLabel.hidden = false;
+  }
+
+  ui.pageLabel.addEventListener('click', openJump);
+  ui.pageInput.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape') {
+      e.preventDefault();
+      closeJump();
+      ui.pageLabel.focus();
+    } else if (e.key === 'Enter') {
+      e.preventDefault();
+      const index = parsePage(ui.pageInput.value, s.pages());
+      if (index === null) {
+        say(`Type a page number from 1 to ${s.pages()}.`);
+        return;
+      }
+      closeJump();
+      ui.pageLabel.focus();
+      if (index !== s.current()) turnTo(index);
+    }
+  });
+  // Tab or another focus change cancels the jump.
+  ui.pageInput.addEventListener('blur', closeJump);
+
+  // A tap outside the open page jump only cancels it: the click that
+  // follows does nothing else.
+  let cancelledAt = -Infinity;
+  document.addEventListener(
+    'pointerdown',
+    (e) => {
+      if (ui.pageInput.hidden || e.target === ui.pageInput) return;
+      closeJump();
+      cancelledAt = e.timeStamp;
+    },
+    true,
+  );
+  document.addEventListener(
+    'click',
+    (e) => {
+      if (e.timeStamp - cancelledAt < CLICK_AFTER_CANCEL_MS) {
+        cancelledAt = -Infinity;
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    },
+    true,
+  );
+
+  return {
+    update() {
+      const pages = s.pages();
+      const page = s.current();
+      const rtl = s.rtl();
+      ui.pageLabel.disabled = pages === 0;
+      ui.pageLabel.textContent = pages > 0 ? `${page + 1} / ${pages}` : 'No PDF';
+      ui.pageLabel.setAttribute(
+        'aria-label',
+        pages > 0 ? `Page ${page + 1} of ${pages}. Go to a page.` : 'No PDF',
+      );
+      const off = arrowsOff(page, pages, rtl);
+      ui.pageLeft.disabled = off.left;
+      ui.pageRight.disabled = off.right;
+      name(ui.pageLeft, arrowStep('left', rtl));
+      name(ui.pageRight, arrowStep('right', rtl));
+      if (pages === 0) closeJump();
+    },
+  };
+}
diff --git a/src/paging.ts b/src/paging.ts
new file mode 100644
index 0000000..5946d3e
--- /dev/null
+++ b/src/paging.ts
@@ -0,0 +1,86 @@
+// Page turns from keys, swipes and the arrows of the top bar. Pure: no DOM.
+//
+// In right-to-left mode (vertical Japanese) the next page is on the left:
+// ArrowLeft, a swipe to the right and the left arrow go forward.
+
+/** A page turn: 1 goes to the next page, -1 to the page before. */
+export type Step = -1 | 1;
+
+/** The shortest swipe that turns a page, in CSS pixels. */
+export const SWIPE_MIN = 50;
+
+/** A swipe turns a page only when its horizontal move is more than this times its vertical move. */
+export const SWIPE_RATIO = 1.5;
+
+/** The step of a key, or 0 for a key that turns no page. */
+export function keyStep(key: string, rtl: boolean): Step | 0 {
+  switch (key) {
+    case 'PageDown':
+      return 1;
+    case 'PageUp':
+      return -1;
+    case 'ArrowRight':
+      return rtl ? -1 : 1;
+    case 'ArrowLeft':
+      return rtl ? 1 : -1;
+    default:
+      return 0;
+  }
+}
+
+export interface Swipe {
+  /** The move of the finger in CSS pixels. A positive dx goes right, a positive dy goes down. */
+  dx: number;
+  dy: number;
+  /** True when the user zoomed in on the page. */
+  zoomed: boolean;
+  /** True when the viewer can scroll sideways. */
+  wide: boolean;
+}
+
+/**
+ * The step of a swipe, or 0 when the swipe turns no page. A finger that
+ * moves to the left goes to the next page, as on paper. A page that is
+ * zoomed in or wider than the viewer needs the swipe to pan, so it does
+ * not turn.
+ */
+export function swipeStep(s: Swipe, rtl: boolean): Step | 0 {
+  if (s.zoomed || s.wide) return 0;
+  const x = Math.abs(s.dx);
+  if (x < SWIPE_MIN || x <= SWIPE_RATIO * Math.abs(s.dy)) return 0;
+  return s.dx < 0 !== rtl ? 1 : -1;
+}
+
+/** The step of the left or the right arrow of the top bar. */
+export function arrowStep(side: 'left' | 'right', rtl: boolean): Step {
+  return (side === 'right') !== rtl ? 1 : -1;
+}
+
+/**
+ * Which arrows are off: an arrow is off when its step leads to no page.
+ * `pages` is 0 when no PDF is open.
+ */
+export function arrowsOff(
+  page: number,
+  pages: number,
+  rtl: boolean,
+): { left: boolean; right: boolean } {
+  const off = (step: Step): boolean => page + step < 0 || page + step >= pages;
+  return { left: off(arrowStep('left', rtl)), right: off(arrowStep('right', rtl)) };
+}
+
+/**
+ * The index of the page that the user typed: a number from 1 to `pages`.
+ * NFKC reads full-width digits. Null for other text.
+ */
+export function parsePage(text: string, pages: number): number | null {
+  const t = text.normalize('NFKC').trim();
+  if (!/^[0-9]+$/.test(t)) return null;
+  const n = Number(t);
+  return n >= 1 && n <= pages ? n - 1 : null;
+}
+
+/** The reading direction before the user sets one: right to left for vertical Japanese. */
+export function defaultRtl(ocrLang: string): boolean {
+  return ocrLang.includes('vert');
+}
diff --git a/src/style.css b/src/style.css
index 0a0a3b9..efa5215 100644
--- a/src/style.css
+++ b/src/style.css
@@ -104,6 +104,19 @@ body {
   font-variant-numeric: tabular-nums;
 }
 
+/* The page jump takes the place of the page label. */
+#page-input {
+  flex: 1 1 auto;
+  min-width: 0;
+  width: 4rem;
+  box-sizing: border-box;
+  height: 2.25rem;
+  padding: 0 0.4rem;
+  font: inherit;
+  font-size: 1rem;
+  text-align: center;
+}
+
 /* The status strip: the player, the reading of the pages, the last event. */
 #strip {
   flex: none;
@@ -286,6 +299,13 @@ body.menu-open #panel {
   /* A scroll bar that comes and goes does not change the width of the page. */
   scrollbar-gutter: stable;
   padding: 0.5rem;
+  /* The browser pans vertically only, so a sideways swipe turns the page (nav.ts). */
+  touch-action: pan-y pinch-zoom;
+}
+
+/* A zoomed page pans in each direction, and a swipe does not turn it. */
+body.zoomed #viewer {
+  touch-action: auto;
 }
 
 /* The start card: what the app does, and how to start. */
diff --git a/test/paging.test.ts b/test/paging.test.ts
new file mode 100644
index 0000000..62f3be9
--- /dev/null
+++ b/test/paging.test.ts
@@ -0,0 +1,149 @@
+import fc from 'fast-check';
+import { describe, expect, it } from 'vitest';
+import {
+  arrowsOff,
+  arrowStep,
+  defaultRtl,
+  keyStep,
+  parsePage,
+  SWIPE_MIN,
+  SWIPE_RATIO,
+  swipeStep,
+  type Swipe,
+} from '../src/paging';
+
+const flat = { zoomed: false, wide: false };
+
+describe('keyStep', () => {
+  it('turns with the arrow keys and the page keys', () => {
+    expect(keyStep('ArrowRight', false)).toBe(1);
+    expect(keyStep('ArrowLeft', false)).toBe(-1);
+    expect(keyStep('PageDown', false)).toBe(1);
+    expect(keyStep('PageUp', false)).toBe(-1);
+    expect(keyStep('ArrowDown', false)).toBe(0);
+    expect(keyStep(' ', false)).toBe(0);
+  });
+
+  it('goes forward with ArrowLeft in right-to-left mode', () => {
+    expect(keyStep('ArrowLeft', true)).toBe(1);
+    expect(keyStep('ArrowRight', true)).toBe(-1);
+    expect(keyStep('PageDown', true)).toBe(1);
+    expect(keyStep('PageUp', true)).toBe(-1);
+  });
+});
+
+describe('swipeStep', () => {
+  it('turns forward for a swipe to the left, back for a swipe to the right', () => {
+    expect(swipeStep({ dx: -80, dy: 10, ...flat }, false)).toBe(1);
+    expect(swipeStep({ dx: 80, dy: -10, ...flat }, false)).toBe(-1);
+  });
+
+  it('turns forward for a swipe to the right in right-to-left mode', () => {
+    expect(swipeStep({ dx: 80, dy: 0, ...flat }, true)).toBe(1);
+    expect(swipeStep({ dx: -80, dy: 0, ...flat }, true)).toBe(-1);
+  });
+
+  it('does not turn for a short swipe, a steep swipe, a zoomed page or a wide page', () => {
+    expect(swipeStep({ dx: -49, dy: 0, ...flat }, false)).toBe(0);
+    expect(swipeStep({ dx: -60, dy: 40, ...flat }, false)).toBe(0);
+    expect(swipeStep({ dx: -80, dy: 0, zoomed: true, wide: false }, false)).toBe(0);
+    expect(swipeStep({ dx: -80, dy: 0, zoomed: false, wide: true }, false)).toBe(0);
+  });
+
+  const swipe: fc.Arbitrary<Swipe> = fc.record({
+    dx: fc.integer({ min: -400, max: 400 }),
+    dy: fc.integer({ min: -400, max: 400 }),
+    zoomed: fc.boolean(),
+    wide: fc.boolean(),
+  });
+
+  it('turns exactly for a long, flat swipe on a page that is not zoomed or wide', () => {
+    fc.assert(
+      fc.property(swipe, fc.boolean(), (s, rtl) => {
+        const turns =
+          !s.zoomed &&
+          !s.wide &&
+          Math.abs(s.dx) >= SWIPE_MIN &&
+          Math.abs(s.dx) > SWIPE_RATIO * Math.abs(s.dy);
+        expect(swipeStep(s, rtl) !== 0).toBe(turns);
+      }),
+    );
+  });
+
+  it('turns the other way for the mirror swipe, and for the other direction', () => {
+    fc.assert(
+      fc.property(swipe, fc.boolean(), (s, rtl) => {
+        const step = swipeStep(s, rtl);
+        expect(swipeStep({ ...s, dx: -s.dx }, rtl)).toBe(-step || 0);
+        expect(swipeStep(s, !rtl)).toBe(-step || 0);
+      }),
+    );
+  });
+});
+
+describe('arrows', () => {
+  it('gives the right arrow the next page, except in right-to-left mode', () => {
+    expect(arrowStep('right', false)).toBe(1);
+    expect(arrowStep('left', false)).toBe(-1);
+    expect(arrowStep('right', true)).toBe(-1);
+    expect(arrowStep('left', true)).toBe(1);
+  });
+
+  it('turns off the arrow that leads to no page', () => {
+    expect(arrowsOff(0, 40, false)).toEqual({ left: true, right: false });
+    expect(arrowsOff(39, 40, false)).toEqual({ left: false, right: true });
+    expect(arrowsOff(0, 40, true)).toEqual({ left: false, right: true });
+    expect(arrowsOff(0, 1, false)).toEqual({ left: true, right: true });
+    // No PDF.
+    expect(arrowsOff(-1, 0, false)).toEqual({ left: true, right: true });
+  });
+
+  it('turns an arrow off exactly when its step leaves the book', () => {
+    const book = fc
+      .integer({ min: 1, max: 500 })
+      .chain((pages) => fc.tuple(fc.constant(pages), fc.nat({ max: pages - 1 }), fc.boolean()));
+    fc.assert(
+      fc.property(book, ([pages, page, rtl]) => {
+        const off = arrowsOff(page, pages, rtl);
+        for (const side of ['left', 'right'] as const) {
+          const to = page + arrowStep(side, rtl);
+          expect(off[side]).toBe(to < 0 || to >= pages);
+        }
+      }),
+    );
+  });
+});
+
+describe('parsePage', () => {
+  it('reads a page number from 1 to the page count', () => {
+    expect(parsePage('30', 40)).toBe(29);
+    expect(parsePage(' 1 ', 40)).toBe(0);
+    expect(parsePage('30', 40)).toBe(29);
+  });
+
+  it('gives null for text that is no page of the book', () => {
+    for (const text of ['', '0', '41', '-3', '2.5', '1e1', 'abc', '3 4']) {
+      expect(parsePage(text, 40)).toBeNull();
+    }
+  });
+
+  it('reads back each page number that it can show', () => {
+    const book = fc
+      .integer({ min: 1, max: 5000 })
+      .chain((pages) => fc.tuple(fc.constant(pages), fc.nat({ max: pages - 1 })));
+    fc.assert(
+      fc.property(book, ([pages, index]) => {
+        expect(parsePage(String(index + 1), pages)).toBe(index);
+      }),
+    );
+  });
+});
+
+describe('defaultRtl', () => {
+  it('is right to left for vertical Japanese only', () => {
+    expect(defaultRtl('jpn_vert')).toBe(true);
+    expect(defaultRtl('jpn')).toBe(false);
+    expect(defaultRtl('jpn+eng')).toBe(false);
+    expect(defaultRtl('eng')).toBe(false);
+  });
+});