Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ app.
- Added support for Daikanwajiten (Morohashi) references
([#2734](https://github.com/birchill/10ten-ja-reader/pull/2734)).
- Worked around a [major string substitution bug in Safari](https://bugs.webkit.org/show_bug.cgi?id=306492).
- Fixed word lookups splitting ruby (`<rt>`) text incorrectly, while still
allowing splits around center dots (`・`)
([#2785](https://github.com/birchill/10ten-ja-reader/issues/2785)).

## [1.26.1] - 2025-12-23

Expand Down
3 changes: 3 additions & 0 deletions src/background/background-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const SourceContextSchema = s.object({
inTranscription: s.optional(s.boolean()),
});

const IndivisibleRangeSchema = s.tuple([s.number(), s.number()]);

export const BackgroundMessageSchema = discriminator('type', {
disable: s.type({ frame: s.literal('*') }),
enable: s.type({
Expand Down Expand Up @@ -53,6 +55,7 @@ export const BackgroundMessageSchema = discriminator('type', {
targetProps: s.type({}),
text: s.string(),
wordLookup: s.boolean(),
indivisibleRanges: s.optional(s.array(IndivisibleRangeSchema)),
// Parameters for designating the iframe source
source: s.type({
frameId: s.number(),
Expand Down
7 changes: 6 additions & 1 deletion src/background/background-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import * as s from 'superstruct';

import { PopupStateSchema } from '../content/popup-state';

const SearchRequestSchema = s.type({ input: s.string() });
const IndivisibleRangeSchema = s.tuple([s.number(), s.number()]);

const SearchRequestSchema = s.type({
input: s.string(),
indivisibleRanges: s.optional(s.array(IndivisibleRangeSchema)),
});

export type SearchRequest = s.Infer<typeof SearchRequestSchema>;

Expand Down
7 changes: 6 additions & 1 deletion src/background/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,13 +396,18 @@ function notifyDbListeners(specifiedListener?: Runtime.Port) {

async function searchWords({
input,
indivisibleRanges,
abortSignal,
}: SearchRequest & {
abortSignal: AbortSignal;
}): Promise<SearchWordsResult | null> {
await dbReady;

const [words, dbStatus] = await jpdictSearchWords({ abortSignal, input });
const [words, dbStatus] = await jpdictSearchWords({
abortSignal,
input,
indivisibleRanges,
});

return { words, dbStatus };
}
Expand Down
4 changes: 4 additions & 0 deletions src/background/jpdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { kanaToHiragana } from '@birchill/normal-jp';
import browser from 'webextension-polyfill';

import type { IndivisibleRanges } from '../common/indivisible-range';
import {
MAX_LOOKUP_LENGTH,
MAX_TRANSLATE_INPUT_LENGTH,
Expand Down Expand Up @@ -306,10 +307,12 @@ const WORDS_MAX_ENTRIES = 7;

export async function searchWords({
input,
indivisibleRanges,
abortSignal,
max = 0,
}: {
input: string;
indivisibleRanges?: IndivisibleRanges;
abortSignal?: AbortSignal;
max?: number;
}): Promise<
Expand Down Expand Up @@ -348,6 +351,7 @@ export async function searchWords({
getWords,
input: word,
inputLengths,
indivisibleRanges,
maxResults,
}),
dbStatus !== 'ok' ? dbStatus : undefined,
Expand Down
130 changes: 130 additions & 0 deletions src/background/word-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';

import { normalizeInput } from '../utils/normalize';

import type { DictionaryWordResult } from './search-result';
import { wordSearch } from './word-search';

describe('wordSearch', () => {
it('does not split inside indivisible ranges while shortening', async () => {
const input = 'けんせいしてたすけに';
const [normalized, inputLengths] = normalizeInput(input);
const lookups: Array<string> = [];

const result = await wordSearch({
getWords: async ({ input }) => {
lookups.push(input);
return input === 'けんせいして'
? [makeWordResult({ id: 1, reading: input })]
: [];
},
input: normalized,
inputLengths,
indivisibleRanges: [[6, 8]],
maxResults: 10,
});

expect(result?.matchLen).toBe(6);
expect(lookups).toContain('けんせいして');
expect(lookups).not.toContain('けんせいしてた');
});

it('allows shortening at center-dot boundaries between indivisible ranges', async () => {
const input = 'あ・い・う';
const [normalized, inputLengths] = normalizeInput(input);
const lookups: Array<string> = [];

const result = await wordSearch({
getWords: async ({ input }) => {
lookups.push(input);
return input === 'あ・い'
? [makeWordResult({ id: 2, reading: input })]
: [];
},
input: normalized,
inputLengths,
indivisibleRanges: [
[0, 1],
[2, 3],
[4, 5],
],
maxResults: 10,
});

expect(result?.matchLen).toBe(3);
expect(lookups).toContain('あ・い');
});

it('does not split a trailing yoon when shortening', async () => {
const [normalized, inputLengths] = normalizeInput('きゃ');
const lookups: Array<string> = [];

await wordSearch({
getWords: async ({ input }) => {
lookups.push(input);
return [];
},
input: normalized,
inputLengths,
maxResults: 10,
});

expect(lookups).toContain('きゃ');
expect(lookups).not.toContain('き');
});

it('tries choon-expanded variants', async () => {
const [normalized, inputLengths] = normalizeInput('そーゆー');
const lookups: Array<string> = [];

const result = await wordSearch({
getWords: async ({ input }) => {
lookups.push(input);
return input === 'そうゆう'
? [makeWordResult({ id: 3, reading: input })]
: [];
},
input: normalized,
inputLengths,
maxResults: 10,
});

expect(result?.matchLen).toBe(4);
expect(lookups).toContain('そうゆう');
});

it('tries 旧字体 to 新字体 variants', async () => {
const [normalized, inputLengths] = normalizeInput('國語');
const lookups: Array<string> = [];

const result = await wordSearch({
getWords: async ({ input }) => {
lookups.push(input);
return input === '国語'
? [makeWordResult({ id: 4, reading: input })]
: [];
},
input: normalized,
inputLengths,
maxResults: 10,
});

expect(result?.matchLen).toBe(2);
expect(lookups).toContain('国語');
});
});

function makeWordResult({
id,
reading,
}: {
id: number;
reading: string;
}): DictionaryWordResult {
return {
id,
k: [],
r: [{ ent: reading, app: 0, matchRange: [0, reading.length] }],
s: [{ g: ['test'], match: true }],
} as unknown as DictionaryWordResult;
}
31 changes: 28 additions & 3 deletions src/background/word-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { PartOfSpeech } from '@birchill/jpdict-idb';
import { AbortError } from '@birchill/jpdict-idb';
import { expandChoon, kyuujitaiToShinjitai } from '@birchill/normal-jp';

import type { IndivisibleRanges } from '../common/indivisible-range';
import { isOnlyDigits } from '../utils/char-range';
import { toRomaji } from '../utils/romaji';

Expand All @@ -26,12 +27,14 @@ export async function wordSearch({
getWords,
input,
inputLengths,
indivisibleRanges,
maxResults,
}: {
abortSignal?: AbortSignal;
getWords: GetWordsFunction;
input: string;
inputLengths: Array<number>;
indivisibleRanges?: IndivisibleRanges;
maxResults: number;
}): Promise<WordSearchResult | null> {
let longestMatch = 0;
Expand Down Expand Up @@ -119,9 +122,22 @@ export async function wordSearch({
break;
}

// Shorten input, but don't split a ようおん (e.g. きゃ).
const lengthToShorten = endsInYoon(input) ? 2 : 1;
input = input.substring(0, input.length - lengthToShorten);
// Shorten input, but don't split a ようおん (e.g. きゃ), and don't split
// any caller-provided indivisible segments (e.g. ruby <rt> text).
let nextInputLength = input.length - (endsInYoon(input) ? 2 : 1);
while (nextInputLength > 0) {
const nextMatchLength = inputLengths[nextInputLength];
if (
typeof nextMatchLength !== 'number' ||
!isInIndivisibleRange(nextMatchLength, indivisibleRanges)
) {
break;
}
nextInputLength -= endsInYoon(input.substring(0, nextInputLength))
? 2
: 1;
}
input = input.substring(0, Math.max(nextInputLength, 0));
}

if (!result.data.length) {
Expand All @@ -132,6 +148,15 @@ export async function wordSearch({
return result;
}

function isInIndivisibleRange(
offset: number,
indivisibleRanges: IndivisibleRanges | undefined
): boolean {
return !!indivisibleRanges?.some(
([start, end]) => offset > start && offset < end
);
}

async function lookupCandidates({
abortSignal,
existingEntries,
Expand Down
3 changes: 3 additions & 0 deletions src/common/indivisible-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type IndivisibleRange = [start: number, end: number];

export type IndivisibleRanges = Array<IndivisibleRange>;
13 changes: 12 additions & 1 deletion src/content/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
} from '../common/content-config-params';
import type { CopyType } from '../common/copy-keys';
import { CopyKeys } from '../common/copy-keys';
import type { IndivisibleRanges } from '../common/indivisible-range';
import { MAX_LOOKUP_LENGTH } from '../common/limits';
import { isEditableNode, isInteractiveElement } from '../utils/dom-utils';
import type { MarginBox, Point, Rect } from '../utils/geometry';
Expand Down Expand Up @@ -236,6 +237,7 @@ export class ContentHandler {
| {
text: string;
wordLookup: boolean;
indivisibleRanges?: IndivisibleRanges;
meta?: SelectionMeta;
source: IframeSourceParams | null;
sourceContext: SourceContext | null;
Expand Down Expand Up @@ -1912,6 +1914,7 @@ export class ContentHandler {

const lookupParams = {
dictMode,
indivisibleRanges: textAtPoint.indivisibleRanges,
meta: textAtPoint.meta,
source: null,
sourceContext: textAtPoint.sourceContext,
Expand Down Expand Up @@ -1951,6 +1954,7 @@ export class ContentHandler {

async lookupText({
dictMode,
indivisibleRanges,
meta,
source,
sourceContext,
Expand All @@ -1959,6 +1963,7 @@ export class ContentHandler {
wordLookup,
}: {
dictMode: 'default' | 'kanji';
indivisibleRanges?: IndivisibleRanges;
meta?: SelectionMeta;
source: IframeSourceParams | null;
sourceContext: SourceContext | null;
Expand All @@ -1970,6 +1975,7 @@ export class ContentHandler {
text,
meta,
wordLookup,
indivisibleRanges,
source,
sourceContext,
};
Expand All @@ -1981,11 +1987,13 @@ export class ContentHandler {
this.isPopupExpanded = false;

const queryResult = await query(text, {
indivisibleRanges,
metaMatchLen: meta?.matchLen,
wordLookup,
updateQueryResult: (queryResult: QueryResult | null) => {
void this.applyQueryResult({
dictMode,
indivisibleRanges,
meta,
queryResult,
targetProps,
Expand All @@ -1997,6 +2005,7 @@ export class ContentHandler {

void this.applyQueryResult({
dictMode,
indivisibleRanges,
meta,
queryResult,
targetProps,
Expand All @@ -2007,20 +2016,22 @@ export class ContentHandler {

async applyQueryResult({
dictMode,
indivisibleRanges,
meta,
queryResult,
targetProps,
text,
wordLookup,
}: {
dictMode: 'default' | 'kanji';
indivisibleRanges?: IndivisibleRanges;
meta?: SelectionMeta;
queryResult: QueryResult | null;
targetProps: TargetProps;
text: string;
wordLookup: boolean;
}) {
const lookupParams = { text, meta, wordLookup };
const lookupParams = { text, meta, wordLookup, indivisibleRanges };

// Check if we have triggered a new query or been disabled while running
// the previous query.
Expand Down
Loading