chaffjs 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/date-stamp.d.ts +14 -0
- package/dist/date-stamp.d.ts.map +1 -0
- package/dist/date-stamp.js +40 -0
- package/dist/date-stamp.js.map +1 -0
- package/dist/declared-lists.d.ts +4 -0
- package/dist/declared-lists.d.ts.map +1 -0
- package/dist/declared-lists.js +3 -0
- package/dist/declared-lists.js.map +1 -0
- package/dist/detectors/acronym-context.d.ts +25 -0
- package/dist/detectors/acronym-context.d.ts.map +1 -0
- package/dist/detectors/acronym-context.js +28 -0
- package/dist/detectors/acronym-context.js.map +1 -0
- package/dist/detectors/acronym-expansion.d.ts +9 -0
- package/dist/detectors/acronym-expansion.d.ts.map +1 -0
- package/dist/detectors/acronym-expansion.js +94 -0
- package/dist/detectors/acronym-expansion.js.map +1 -0
- package/dist/detectors/char-shape.d.ts.map +1 -1
- package/dist/detectors/char-shape.js +20 -34
- package/dist/detectors/char-shape.js.map +1 -1
- package/dist/detectors/doubled-word.d.ts +27 -0
- package/dist/detectors/doubled-word.d.ts.map +1 -0
- package/dist/detectors/doubled-word.js +94 -0
- package/dist/detectors/doubled-word.js.map +1 -0
- package/dist/detectors/heading-echo.d.ts.map +1 -1
- package/dist/detectors/heading-echo.js +22 -12
- package/dist/detectors/heading-echo.js.map +1 -1
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +2 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lead-in.d.ts +8 -0
- package/dist/detectors/lead-in.d.ts.map +1 -0
- package/dist/detectors/lead-in.js +13 -0
- package/dist/detectors/lead-in.js.map +1 -0
- package/dist/detectors/lexicon-match.d.ts +6 -0
- package/dist/detectors/lexicon-match.d.ts.map +1 -1
- package/dist/detectors/lexicon-match.js +8 -0
- package/dist/detectors/lexicon-match.js.map +1 -1
- package/dist/detectors/lexicon.d.ts +5 -0
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +21 -4
- package/dist/detectors/lexicon.js.map +1 -1
- package/dist/detectors/orthography.d.ts.map +1 -1
- package/dist/detectors/orthography.js +17 -1
- package/dist/detectors/orthography.js.map +1 -1
- package/dist/detectors/place-run.d.ts +8 -0
- package/dist/detectors/place-run.d.ts.map +1 -0
- package/dist/detectors/place-run.js +54 -0
- package/dist/detectors/place-run.js.map +1 -0
- package/dist/detectors/register.d.ts +32 -0
- package/dist/detectors/register.d.ts.map +1 -0
- package/dist/detectors/register.js +62 -0
- package/dist/detectors/register.js.map +1 -0
- package/dist/detectors/sentence-ending.d.ts.map +1 -1
- package/dist/detectors/sentence-ending.js +17 -28
- package/dist/detectors/sentence-ending.js.map +1 -1
- package/dist/detectors/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +20 -115
- package/dist/detectors/signals.js.map +1 -1
- package/dist/detectors/structure.d.ts.map +1 -1
- package/dist/detectors/structure.js +14 -1
- package/dist/detectors/structure.js.map +1 -1
- package/dist/detectors/superlative-scope.d.ts +16 -0
- package/dist/detectors/superlative-scope.d.ts.map +1 -0
- package/dist/detectors/superlative-scope.js +40 -0
- package/dist/detectors/superlative-scope.js.map +1 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +17 -11
- package/dist/document.js.map +1 -1
- package/dist/eval.d.ts.map +1 -1
- package/dist/eval.js +3 -2
- package/dist/eval.js.map +1 -1
- package/dist/heading-title.d.ts +6 -0
- package/dist/heading-title.d.ts.map +1 -0
- package/dist/heading-title.js +8 -0
- package/dist/heading-title.js.map +1 -0
- package/dist/in-page-nav.d.ts +19 -0
- package/dist/in-page-nav.d.ts.map +1 -0
- package/dist/in-page-nav.js +44 -0
- package/dist/in-page-nav.js.map +1 -0
- package/dist/number-name.d.ts +9 -0
- package/dist/number-name.d.ts.map +1 -0
- package/dist/number-name.js +57 -0
- package/dist/number-name.js.map +1 -0
- package/dist/orthography.d.ts +7 -2
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +79 -17
- package/dist/orthography.js.map +1 -1
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +1 -0
- package/dist/rule-load.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +5 -4
- package/dist/run.js.map +1 -1
- package/package.json +3 -3
- package/rules/doubled-word.yaml +27 -0
- package/rules/latin-spacing.yaml +4 -2
- package/rules/max-kanji-continuous.yaml +2 -0
- package/rules/undefined-acronym.yaml +2 -0
- package/rules/unqualified-superlative.yaml +1 -0
- package/src/date-stamp.ts +65 -0
- package/src/declared-lists.ts +5 -0
- package/src/detectors/acronym-context.ts +50 -0
- package/src/detectors/acronym-expansion.ts +109 -0
- package/src/detectors/char-shape.ts +12 -30
- package/src/detectors/doubled-word.ts +128 -0
- package/src/detectors/heading-echo.ts +13 -3
- package/src/detectors/index.ts +2 -0
- package/src/detectors/lead-in.ts +14 -0
- package/src/detectors/lexicon-match.ts +10 -0
- package/src/detectors/lexicon.ts +32 -6
- package/src/detectors/orthography.ts +20 -2
- package/src/detectors/place-run.ts +63 -0
- package/src/detectors/register.ts +83 -0
- package/src/detectors/sentence-ending.ts +24 -28
- package/src/detectors/signals.ts +24 -120
- package/src/detectors/structure.ts +21 -2
- package/src/detectors/superlative-scope.ts +66 -0
- package/src/document.ts +19 -11
- package/src/eval.ts +2 -1
- package/src/heading-title.ts +8 -0
- package/src/in-page-nav.ts +67 -0
- package/src/number-name.ts +68 -0
- package/src/orthography.ts +77 -15
- package/src/plugin.ts +4 -0
- package/src/rule-load.ts +1 -0
- package/src/run.ts +5 -4
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
id: doubled-word
|
|
2
|
+
layer: L3
|
|
3
|
+
status: experimental
|
|
4
|
+
how_to_find: doubled-word
|
|
5
|
+
word_list: doubled-word
|
|
6
|
+
requires: [pos]
|
|
7
|
+
severity: warning
|
|
8
|
+
|
|
9
|
+
name:
|
|
10
|
+
ja: 語を二度書いている
|
|
11
|
+
en: Word written twice
|
|
12
|
+
|
|
13
|
+
why:
|
|
14
|
+
ja: 「資料をを送ります」「確認確認する」のように同じ語が続くのは、消し忘れか、書き換えの途中で残った語です。読み手はつまずき、急いで書いた文書に見えます。同じ語でも品詞が違うものと、重ねて強める書き方(「そうそう」「一つ一つ」)は数えていません。
|
|
15
|
+
en: The same word twice in a row ("the the"), or two words where English takes one ("our the platform"), is left over from an edit. The reader stumbles, and the document looks rushed. Repeats that grammar allows ("had had", "that that", "is, is") and repeats for emphasis ("very very") are not counted.
|
|
16
|
+
|
|
17
|
+
message:
|
|
18
|
+
ja: 「{word}」で語が重なっています。どちらかが余分です
|
|
19
|
+
en: '"{word}" doubles a word; one of the two is left over'
|
|
20
|
+
|
|
21
|
+
how_to_fix:
|
|
22
|
+
ja: 余分なほうを消してください。書き換えの途中なら、残したい言い方に揃えてください。
|
|
23
|
+
en: Delete the extra word. If a rewrite was half done ("our the platform"), keep the wording you meant.
|
|
24
|
+
|
|
25
|
+
levels: { strict: 1, normal: 1, relaxed: 1 }
|
|
26
|
+
|
|
27
|
+
use_for: [blog, business, technical]
|
package/rules/latin-spacing.yaml
CHANGED
|
@@ -4,14 +4,16 @@ status: experimental
|
|
|
4
4
|
how_to_find: latin-spacing
|
|
5
5
|
languages: [ja]
|
|
6
6
|
severity: warning
|
|
7
|
+
# 番号・識別子として書かれた数を見分けるのに、数の後ろの語の品詞を使う。無ければ全部数える。
|
|
8
|
+
uses: [pos]
|
|
7
9
|
|
|
8
10
|
name:
|
|
9
11
|
ja: 英数字の前後の空白がそろっていない
|
|
10
12
|
en: Spacing around Latin text is inconsistent
|
|
11
13
|
|
|
12
14
|
why:
|
|
13
|
-
ja: 日本語と英字・数字のあいだを空けるか詰めるかは、どちらでも読めます。困るのは一つの文書の中で混ざることで、読み手には違いに意味があるように見えます。chaff はどちらが正しいかを決めず、文書の中で少ないほうを指摘します。英字と、数字の前、数字の後ろは別々に数えます。「を 3回」のように、数字の前は空けて助数詞とは詰める書き方がふつうにあるからです。「3GB
|
|
14
|
-
en: Whether to put a space between Japanese and Latin letters or digits is a choice either way. What reads badly is mixing both in one document, which suggests the difference means something. chaff does not decide which is right; it points at whichever the document uses less. Letters, the space before a digit and the space after one are counted separately, since spacing before a number but not before its counter is common.
|
|
15
|
+
ja: 日本語と英字・数字のあいだを空けるか詰めるかは、どちらでも読めます。困るのは一つの文書の中で混ざることで、読み手には違いに意味があるように見えます。chaff はどちらが正しいかを決めず、文書の中で少ないほうを指摘します。英字と、数字の前、数字の後ろは別々に数えます。「を 3回」のように、数字の前は空けて助数詞とは詰める書き方がふつうにあるからです。「3GBの」のように数字に続く単位は、英字ではなく数字の側として数えます。逆に「H30 等」「EC2 で」「Phase 1 は」のように英字で始まって数字で終わる名前は、英字の側として数えます。「073-489-5909」「2026-06-02」のような番号は数量ではないので数えません。題や注の番号として書かれた数(「3.1 リサーチの原則」の節番号、※1 の注番号)と、0 で始まる組を含む番号(〒102-0094)も、後ろの空白が番号と題・項目の区切りなので数えません。
|
|
16
|
+
en: Whether to put a space between Japanese and Latin letters or digits is a choice either way. What reads badly is mixing both in one document, which suggests the difference means something. chaff does not decide which is right; it points at whichever the document uses less. Letters, the space before a digit and the space after one are counted separately, since spacing before a number but not before its counter is common. A unit after a number (3GB) counts with the number; a name that starts with a letter and ends in a digit (H30, EC2, Phase 1) counts with the letters. A code such as a phone number or a date (073-489-5909, 2026-06-02) is not a quantity and is not counted. Neither is a number written as the label of a title or a note (a section number such as 3.1 before its title, a note number after ※) or a code with a part starting with 0 (〒102-0094), since the space after it separates the label from its title or the next item.
|
|
15
17
|
|
|
16
18
|
message:
|
|
17
19
|
ja: "日本語と{kind}のあいだを{style}(この文書はふつう{usual}。{of} 箇所のうち {count} 箇所が違う)"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Span } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** 段落 1 つ。dates は段落の中の日付の位置で、段落の先頭から数える。 */
|
|
4
|
+
export type StampCandidate = { readonly text: string; readonly dates: readonly Span[] };
|
|
5
|
+
|
|
6
|
+
/** 段落ごとの候補。dates は文書の中の日付の位置で、段落の中にすっかり収まるものだけを段落の先頭からの位置にする。 */
|
|
7
|
+
export const stampCandidates = (source: string, paragraphs: readonly Span[], dates: readonly Span[]): StampCandidate[] =>
|
|
8
|
+
paragraphs.map((paragraph) => ({
|
|
9
|
+
text: source.slice(paragraph.start, paragraph.end),
|
|
10
|
+
dates: dates
|
|
11
|
+
.filter((date) => date.start >= paragraph.start && date.end <= paragraph.end)
|
|
12
|
+
.map((date) => ({ start: date.start - paragraph.start, end: date.end - paragraph.start })),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
/** 文字か数字。日付を除いてこれが 1 つも残らなければ、日付だけの段落。 */
|
|
16
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
17
|
+
const ENDS_WITH_COLON = /[::]\s*$/u;
|
|
18
|
+
const LABEL_EDGE = /[\s::]/u;
|
|
19
|
+
|
|
20
|
+
/** 幅の無い日付は日付として読まない。段落が日付だけかどうかを、何も覆わない位置で決めない。 */
|
|
21
|
+
const datesOf = (candidate: StampCandidate): Span[] => candidate.dates.filter((date) => date.end > date.start);
|
|
22
|
+
|
|
23
|
+
const withoutDates = (candidate: StampCandidate): string => {
|
|
24
|
+
const ordered = datesOf(candidate).sort((left, right) => left.start - right.start);
|
|
25
|
+
const gaps = ordered.reduce<{ readonly parts: readonly string[]; readonly from: number }>(
|
|
26
|
+
(acc, date) => ({ parts: [...acc.parts, candidate.text.slice(acc.from, date.start)], from: Math.max(acc.from, date.end) }),
|
|
27
|
+
{ parts: [], from: 0 },
|
|
28
|
+
);
|
|
29
|
+
return [...gaps.parts, candidate.text.slice(gaps.from)].join(" ");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** 前後の空白とコロンを外したラベル。「Last updated:」も「:最終更新日」も同じ語として引く。 */
|
|
33
|
+
const bareLabel = (text: string): string => {
|
|
34
|
+
const chars = [...text];
|
|
35
|
+
const first = chars.findIndex((char) => !LABEL_EDGE.test(char));
|
|
36
|
+
const last = chars.findLastIndex((char) => !LABEL_EDGE.test(char));
|
|
37
|
+
return first === -1 ? "" : chars.slice(first, last + 1).join("");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const isLabel = (text: string, labels: readonly string[]): boolean => {
|
|
41
|
+
const bare = bareLabel(text).toLowerCase();
|
|
42
|
+
return bare !== "" && labels.some((label) => label.toLowerCase() === bare);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const isDateOnly = (candidate: StampCandidate | undefined): boolean =>
|
|
46
|
+
candidate !== undefined && datesOf(candidate).length > 0 && !WORD.test(withoutDates(candidate));
|
|
47
|
+
|
|
48
|
+
/** 「Updated 2026-03-03」「最終更新日:2025年6月20日」。日付を除くと、語彙表のラベルしか残らない。 */
|
|
49
|
+
const isLabelledDate = (candidate: StampCandidate, labels: readonly string[]): boolean =>
|
|
50
|
+
datesOf(candidate).length > 0 && isLabel(withoutDates(candidate), labels);
|
|
51
|
+
|
|
52
|
+
/** 「最終更新日:」の段落のすぐ後に日付だけの段落。Web ページを写すと、ラベルと日付が別の段落に割れる。 */
|
|
53
|
+
const isLabelBeforeDate = (candidate: StampCandidate, next: StampCandidate | undefined, labels: readonly string[]): boolean =>
|
|
54
|
+
ENDS_WITH_COLON.test(candidate.text) && isLabel(candidate.text, labels) && isDateOnly(next);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 更新日・公開日の刻印になっている段落の位置。ページの付帯情報で、書いた人が読み手に読ませる前置きではない。
|
|
58
|
+
* ラベルの語は言語パッケージの語彙表(date-stamp-label)から渡す。文の中に日付があるだけの段落は刻印ではない。
|
|
59
|
+
*/
|
|
60
|
+
export const dateStampIndexes = (candidates: readonly StampCandidate[], labels: readonly string[]): ReadonlySet<number> =>
|
|
61
|
+
new Set(
|
|
62
|
+
candidates.flatMap((candidate, index) =>
|
|
63
|
+
isDateOnly(candidate) || isLabelledDate(candidate, labels) || isLabelBeforeDate(candidate, candidates[index + 1], labels) ? [index] : [],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Lexicon, RuleDefinition } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** rule が宣言した語彙表のうち、その言語に無い最初のもの。無いまま動かすと、照らす語が無いだけの 0 件や指摘が本物に見える。 */
|
|
4
|
+
export const missingList = (rule: RuleDefinition, lexicons: Readonly<Record<string, Lexicon>>): string | undefined =>
|
|
5
|
+
[...(rule.word_list === undefined ? [] : [rule.word_list]), ...rule.extra_word_lists].find((name) => lexicons[name] === undefined);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 大文字の語が略語ではなく、決まった書き方の一部として読める所(3:30 PM、1pm ET、USD 1,000、Kansas City, MO 64108)。
|
|
5
|
+
* どれも閉じた集合で、しかも隣の数字があるときだけ認める。PM(project manager)、CT、CA(certificate authority)は
|
|
6
|
+
* 数字の隣でなければ略語のまま数える。語は言語パッケージの語彙表から読み、書き方の組み立てだけをここに置く。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type Span = { readonly start: number; readonly end: number };
|
|
10
|
+
|
|
11
|
+
/** 言語パッケージの語彙表から読む、数字の隣でだけ略語から外す語と、単独で強調として書かれる語。字面どおりに照らす。 */
|
|
12
|
+
export type NotationWords = {
|
|
13
|
+
readonly meridiem: readonly string[];
|
|
14
|
+
readonly timeZones: readonly string[];
|
|
15
|
+
readonly currencies: readonly string[];
|
|
16
|
+
readonly usStates: readonly string[];
|
|
17
|
+
readonly emphasis: readonly string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// 空の語彙表は「その書き方が無い」。空の選択肢 (?:) は至る所で空文字に当たるので、何にも当たらない形にする。
|
|
21
|
+
const oneOf = (words: readonly string[]): string => (words.length === 0 ? "(?!)" : `(?:${words.map(escapeRegExp).join("|")})`);
|
|
22
|
+
|
|
23
|
+
const MINUTES = String.raw`:[0-5]\d`;
|
|
24
|
+
const CLOCK_12 = String.raw`(?<![\d:.])(?:1[0-2]|0?[1-9])(?:${MINUTES})?`;
|
|
25
|
+
const CLOCK_24 = String.raw`(?<![\d:.])(?:[01]?\d|2[0-3])${MINUTES}`;
|
|
26
|
+
/** 時間帯の前の午前・午後は書き方を問わない(pm、p.m.、P.M.、PM)。 */
|
|
27
|
+
const ANY_MERIDIEM = String.raw`[AaPp]\.?[Mm]\.?`;
|
|
28
|
+
/** 桁区切りは 3 桁ずつ揃っているときだけ金額と読む。「item 1, USD」の 1, は金額ではない。 */
|
|
29
|
+
const AMOUNT = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?`;
|
|
30
|
+
|
|
31
|
+
// 強調の記号は空白に置き換えてある(**3:30** PM)ので、部品の間の空白は数を問わない。
|
|
32
|
+
const patternsOf = (words: NotationWords): readonly RegExp[] => [
|
|
33
|
+
new RegExp(String.raw`${CLOCK_12}\s*${oneOf(words.meridiem)}`, "gu"),
|
|
34
|
+
new RegExp(String.raw`(?:${CLOCK_24}|${CLOCK_12}\s*${ANY_MERIDIEM})\s*${oneOf(words.timeZones)}`, "gu"),
|
|
35
|
+
new RegExp(String.raw`${oneOf(words.currencies)}\s*[$€£¥]?${AMOUNT}`, "gu"),
|
|
36
|
+
new RegExp(String.raw`(?<![\w.,])${AMOUNT}\s*${oneOf(words.currencies)}`, "gu"),
|
|
37
|
+
new RegExp(String.raw`,\s+${oneOf(words.usStates)}\s+\d{5}(?:-\d{4})?(?!\d)`, "gu"),
|
|
38
|
+
new RegExp(oneOf(words.emphasis), "gu"),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export type NotAcronymSpans = (text: string) => Span[];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 文の中で、略語として数えない大文字を含む範囲。語彙表から一度だけ組み立てて、文ごとに呼ぶ。
|
|
45
|
+
* 略語は範囲にまるごと入るときだけ外れるので、語の境目は見なくてよい(NOTE の中の NOT)。
|
|
46
|
+
*/
|
|
47
|
+
export const notAcronymSpansOf = (words: NotationWords): NotAcronymSpans => {
|
|
48
|
+
const patterns = patternsOf(words);
|
|
49
|
+
return (text) => patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => ({ start: match.index, end: match.index + match[0].length })));
|
|
50
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { escapeRegExp } from "../orthography.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 展開は略語の**すぐ隣**にあるときだけ認める。
|
|
5
|
+
* 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
|
|
6
|
+
*
|
|
7
|
+
* 認めるのは次の形。どれも実際によく書かれる。
|
|
8
|
+
* CI(継続的インテグレーション) 略語のあとに括弧
|
|
9
|
+
* Continuous Integration (CI) 括弧の中が略語
|
|
10
|
+
* Tax Cuts and Jobs Act [TCJA] 角括弧の中が略語で、直前の語の頭文字と揃う
|
|
11
|
+
* Relief Act (RA; P.L. 112-240) 括弧の最初の項目が略語で、直前の語か区切りの後の語の頭文字と揃う
|
|
12
|
+
* 人事部(以下「HR」という。) 括弧の中が定義の語と略語だけ
|
|
13
|
+
* 括弧と略語の間には、引用符((“MNDA”)、(「MNDA」))と空白だけを許す。
|
|
14
|
+
*/
|
|
15
|
+
const WRAP = String.raw`[\s"“”'‘’「」『』]*`;
|
|
16
|
+
const OPENS = new RegExp(String.raw`^${WRAP}[((]`, "u");
|
|
17
|
+
const CLOSES = new RegExp(String.raw`^${WRAP}[))]`, "u");
|
|
18
|
+
const OPENED = new RegExp(String.raw`[((]${WRAP}$`, "u");
|
|
19
|
+
const SQUARE_CLOSES = new RegExp(String.raw`^${WRAP}\]`, "u");
|
|
20
|
+
const SQUARE_OPENED = new RegExp(String.raw`\[${WRAP}$`, "u");
|
|
21
|
+
const ANY_OPENED = new RegExp(String.raw`[(([]${WRAP}$`, "u");
|
|
22
|
+
/** 括弧の最初の項目のあとの区切りと、括弧が閉じるまでの残り((ARRA; P.L. 111-5)、(OP、Originator Profile))。 */
|
|
23
|
+
const SEPARATED = new RegExp(String.raw`^${WRAP}[;;,,、](?<rest>[^()()]*)`, "u");
|
|
24
|
+
const QUOTES = /["“”'‘’「」『』]/gu;
|
|
25
|
+
|
|
26
|
+
/** 括弧と略語の間の幅。空白は 1 つに畳んであるので、(“ MNDA ”) まで収まる。 */
|
|
27
|
+
const NEAR = 3;
|
|
28
|
+
|
|
29
|
+
/** 定義の語を挟む形で、括弧から略語まで・略語から括弧までに見る幅。(hereinafter referred to as “SLA”) が収まる。 */
|
|
30
|
+
const DEFINITION_REACH = 60;
|
|
31
|
+
|
|
32
|
+
/** 定義の語どうし、定義の語と略語の間に来てよいもの(以下、HR)。英字が続けば語の途中((theSLA))。 */
|
|
33
|
+
const MARKER_GAP = String.raw`(?![A-Za-z])[\s、,]*`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 角括弧は引用の印にも使う([IANA]、[1])。直前の語のうち大文字で始まる語の頭文字が
|
|
37
|
+
* 略語と揃うときだけ展開と見なす。見る語は略語の文字数の 2 倍まで(and や of を挟むため)。
|
|
38
|
+
*/
|
|
39
|
+
const lettersOf = (acronym: string): string => acronym.replaceAll("&", "");
|
|
40
|
+
|
|
41
|
+
const initialsOf = (words: readonly string[]): string =>
|
|
42
|
+
words
|
|
43
|
+
.filter((word) => /^[A-Z]/u.test(word))
|
|
44
|
+
.map((word) => word.charAt(0))
|
|
45
|
+
.join("");
|
|
46
|
+
|
|
47
|
+
const spellsOut = (before: string, acronym: string): boolean => {
|
|
48
|
+
const letters = lettersOf(acronym);
|
|
49
|
+
const words = before
|
|
50
|
+
.replace(ANY_OPENED, "")
|
|
51
|
+
.trim()
|
|
52
|
+
.split(/\s+/u)
|
|
53
|
+
.slice(-letters.length * 2);
|
|
54
|
+
return initialsOf(words).endsWith(letters);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 括弧の最初の項目が略語で、区切りのあとに注記が続く形((ARRA; P.L. 111-5)、(OP、Originator Profile))。
|
|
59
|
+
* 同じ形で列挙も書く((MR, handbook, etc.)、(EPA, FDIC, GSA))ので、直前の語か、区切りから括弧が閉じるまでの語の
|
|
60
|
+
* 頭文字が略語と揃うときだけ展開と見なす。区切りの後ろは閉じた括弧の中だけを見て、頭文字がちょうど略語になることを求める。
|
|
61
|
+
*/
|
|
62
|
+
const isSeparatedAt = (body: string, acronym: string, at: number): boolean => {
|
|
63
|
+
if (!OPENED.test(body.slice(Math.max(0, at - NEAR), at))) return false;
|
|
64
|
+
const separated = SEPARATED.exec(body.slice(at + acronym.length, at + acronym.length + DEFINITION_REACH));
|
|
65
|
+
if (separated === null) return false;
|
|
66
|
+
const rest = separated.groups?.["rest"] ?? "";
|
|
67
|
+
const end = at + acronym.length + separated[0].length;
|
|
68
|
+
const closed = CLOSES.test(body.slice(end, end + NEAR));
|
|
69
|
+
return spellsOut(body.slice(0, at), acronym) || (closed && initialsOf(rest.replace(QUOTES, " ").trim().split(/\s+/u)) === lettersOf(acronym));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** 言語パッケージの語彙表から読む、括弧の中で略語の前に書く語(以下、hereinafter)と後ろに書く語(という)。 */
|
|
73
|
+
export type DefinitionWords = { readonly markers: readonly string[]; readonly verbs: readonly string[] };
|
|
74
|
+
|
|
75
|
+
type DefinitionPatterns = { readonly opened: RegExp; readonly closes: RegExp };
|
|
76
|
+
|
|
77
|
+
const oneOf = (words: readonly string[]): string => (words.length === 0 ? "(?!)" : words.map(escapeRegExp).join("|"));
|
|
78
|
+
|
|
79
|
+
const definitionPatterns = (words: DefinitionWords): DefinitionPatterns => ({
|
|
80
|
+
opened: new RegExp(String.raw`[((]\s*(?<markers>(?:(?:${oneOf(words.markers)})${MARKER_GAP})*)${WRAP}(?<![A-Za-z])$`, "iu"),
|
|
81
|
+
closes: new RegExp(String.raw`^${WRAP}(?<verb>(?:${oneOf(words.verbs)})[。.]?)?${WRAP}[))]`, "iu"),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 括弧の中に定義の語と略語だけがある形。語が 1 つも無い(CI)は OPENED と CLOSES が見るので、ここでは数えない。
|
|
86
|
+
* 括弧の中身が語と略語で尽きることを求めるので、ふつうの意味の「以下」((以下のSRE手順))は定義にならない。
|
|
87
|
+
*/
|
|
88
|
+
const isDefinedAt = (patterns: DefinitionPatterns, body: string, acronym: string, at: number): boolean => {
|
|
89
|
+
const opened = patterns.opened.exec(body.slice(Math.max(0, at - DEFINITION_REACH), at));
|
|
90
|
+
const closes = patterns.closes.exec(body.slice(at + acronym.length, at + acronym.length + DEFINITION_REACH));
|
|
91
|
+
if (opened === null || closes === null) return false;
|
|
92
|
+
return (opened.groups?.["markers"] ?? "") !== "" || (closes.groups?.["verb"] ?? "") !== "";
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isBracketedAt = (body: string, acronym: string, at: number): boolean => {
|
|
96
|
+
const after = body.slice(at + acronym.length, at + acronym.length + NEAR);
|
|
97
|
+
const before = body.slice(Math.max(0, at - NEAR), at);
|
|
98
|
+
if (OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after))) return true;
|
|
99
|
+
if (SQUARE_OPENED.test(before) && SQUARE_CLOSES.test(after) && spellsOut(body.slice(0, at), acronym)) return true;
|
|
100
|
+
return isSeparatedAt(body, acronym, at);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type ExpandedAt = (body: string, acronym: string, at: number) => boolean;
|
|
104
|
+
|
|
105
|
+
/** body の at にある略語が、その場で展開・定義されているか。語彙表から一度だけ組み立てて、略語ごとに呼ぶ。 */
|
|
106
|
+
export const expansionAt = (words: DefinitionWords): ExpandedAt => {
|
|
107
|
+
const patterns = definitionPatterns(words);
|
|
108
|
+
return (body, acronym, at) => isBracketedAt(body, acronym, at) || isDefinedAt(patterns, body, acronym, at);
|
|
109
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { Detector, DocumentProfile, Finding, Sentence
|
|
1
|
+
import type { Detector, DocumentProfile, Finding, Sentence } from "../plugin.ts";
|
|
2
2
|
import { compacted, placeOf } from "./gram-place.ts";
|
|
3
3
|
import { maskAddresses } from "../address-chain.ts";
|
|
4
|
+
import { isAddressRun } from "./place-run.ts";
|
|
4
5
|
import { proseText } from "../measure.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -9,50 +10,30 @@ import { proseText } from "../measure.ts";
|
|
|
9
10
|
*/
|
|
10
11
|
const KANJI_RUN = /[一-鿿]+/gu;
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
14
|
-
const isGeo = (token: Token | undefined): boolean => isGeoName(token) || token?.features?.["NameType"] === "GeoUnit";
|
|
15
|
-
const isNumber = (token: Token | undefined): boolean => token?.features?.["NumType"] === "Card";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* 住所の語: 地名と地名の単位(NameType=Geo / GeoUnit)、数(NumType=Card)、数のすぐ後ろの助数詞(丁目、NounType=Class)。
|
|
19
|
-
* 辞書に無い町名(新橋)は、地名の後ろで数の前に来るときだけ住所の一部とする。
|
|
20
|
-
*/
|
|
21
|
-
const isPlacePart = (tokens: readonly Token[], index: number): boolean =>
|
|
22
|
-
isGeo(tokens[index]) ||
|
|
23
|
-
isNumber(tokens[index]) ||
|
|
24
|
-
(isNumber(tokens[index - 1]) && tokens[index]?.features?.["NounType"] === "Class") ||
|
|
25
|
-
(isGeo(tokens[index - 1]) && isNumber(tokens[index + 1]));
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* 漢字の連なりが住所か(東京都港区新橋二丁目)。形態素解析が地名と読んだ語で始まり、住所の語だけでできている。
|
|
29
|
-
* 住所は決まった形で、ひらがなを挟んで書けない。組織名(日本経済団体連合会)は地名の後ろに普通の語が続くので住所ではない。品詞が無ければ判定しない。
|
|
30
|
-
*/
|
|
31
|
-
const isPlaceName = (sentence: Sentence, run: string): boolean => {
|
|
13
|
+
/** 漢字の連なりが住所か(東京都港区新橋二丁目)。形態素解析の地名と数で決める。品詞が無ければ判定しない。 */
|
|
14
|
+
const isPlaceName = (sentence: Sentence, run: string, topUnits: ReadonlySet<string>): boolean => {
|
|
32
15
|
const tokens = sentence.tokens;
|
|
33
16
|
const place = tokens === undefined ? undefined : placeOf(compacted(sentence.text, "word"), run);
|
|
34
17
|
if (tokens === undefined || place === undefined) return false;
|
|
35
18
|
const [start, end] = [sentence.span.start + place.start, sentence.span.start + place.end];
|
|
36
19
|
const covering = tokens.flatMap((token, index) => (token.span.start < end && start < token.span.end ? [index] : []));
|
|
37
|
-
|
|
38
|
-
// 地名が単位を挟まずに続けば(東京大阪名古屋福岡)、住所ではなく地名の並び。
|
|
39
|
-
const listed = covering.some((index) => isGeoName(tokens[index]) && isGeoName(tokens[index + 1]));
|
|
40
|
-
return first !== undefined && isGeo(tokens[first]) && !listed && covering.every((index) => isPlacePart(tokens, index));
|
|
20
|
+
return isAddressRun(tokens, covering, topUnits);
|
|
41
21
|
};
|
|
42
22
|
|
|
43
|
-
const longestKanji = (sentence: Sentence, profile: DocumentProfile | undefined): string =>
|
|
23
|
+
const longestKanji = (sentence: Sentence, profile: DocumentProfile | undefined, topUnits: ReadonlySet<string>): string =>
|
|
44
24
|
[...maskAddresses(proseText(sentence), profile).matchAll(KANJI_RUN)]
|
|
45
25
|
.map((match) => match[0])
|
|
46
|
-
.filter((run) => !isPlaceName(sentence, run))
|
|
26
|
+
.filter((run) => !isPlaceName(sentence, run, topUnits))
|
|
47
27
|
.reduce((longest, run) => (run.length > longest.length ? run : longest), "");
|
|
48
28
|
|
|
49
29
|
/**
|
|
50
30
|
* 漢字が続くと、どこで語が切れるのか読み手が探すことになる。
|
|
51
31
|
* 「情報処理推進機構認定試験」は 12 字。ひらがなを 1 つ挟むだけで読める。
|
|
52
32
|
*/
|
|
53
|
-
export const kanjiRun: Detector = (doc, options): Finding[] =>
|
|
54
|
-
doc.
|
|
55
|
-
|
|
33
|
+
export const kanjiRun: Detector = (doc, options): Finding[] => {
|
|
34
|
+
const topUnits = new Set((doc.lexicons["prefecture-unit"] ?? []).map((entry) => entry.pattern));
|
|
35
|
+
return doc.sentences
|
|
36
|
+
.map((sentence) => ({ sentence, run: longestKanji(sentence, doc.profile, topUnits) }))
|
|
56
37
|
.filter(({ run }) => run.length > options.limit)
|
|
57
38
|
.map(({ sentence, run }) => ({
|
|
58
39
|
rule: "max-kanji-continuous",
|
|
@@ -62,6 +43,7 @@ export const kanjiRun: Detector = (doc, options): Finding[] =>
|
|
|
62
43
|
quote: sentence.text.trim(),
|
|
63
44
|
values: { word: run, count: run.length, limit: options.limit, offset: sentence.span.start },
|
|
64
45
|
}));
|
|
46
|
+
};
|
|
65
47
|
|
|
66
48
|
const MIDDLE_DOT = /・/gu;
|
|
67
49
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Detector, Finding, Lexicon, Sentence, Token } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 語を書き損じて二度書いた(the the / 資料をを)。消し忘れと、書き換えの途中で残った語(our the platform)の二つ。
|
|
5
|
+
*
|
|
6
|
+
* 同じ語は、表層(大文字小文字は問わない)と品詞の両方が同じときだけ。「that that」は接続詞と指示語で品詞が違う。
|
|
7
|
+
* 冠詞と所有の語は名詞の前に一つしか立たないので、違う語でも二つ並べば書き損じ。印はアダプタが UD の FEATS で付ける。
|
|
8
|
+
* どの言語でも書き損じでない重なり(had had / 一つ一つ)は、言語ごとの語彙表に置く。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 重ねても書き損じにならない品詞。記号と数は語ではない。間投詞(はいはい / no no)と副詞(そうそう / very very)は
|
|
13
|
+
* 重ねて強める書き方がどの語でもできるので、語彙表では尽くせない。固有名詞は重なった名前(Walla Walla)がある。
|
|
14
|
+
*/
|
|
15
|
+
const REPEATABLE = new Set(["PUNCT", "SYM", "NUM", "X", "INTJ", "ADV", "PROPN"]);
|
|
16
|
+
|
|
17
|
+
/** 強調やリンクの印を挟んで重なった内容語は、画面名とその名詞(**app settings** settings screen)。機能語だけが書き損じ。 */
|
|
18
|
+
const FUNCTION_WORD = new Set(["DET", "ADP", "PRON", "AUX", "CCONJ", "SCONJ", "PART"]);
|
|
19
|
+
|
|
20
|
+
const LETTER = /\p{L}/u;
|
|
21
|
+
const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
|
|
22
|
+
const SPACE_ONLY = /^\s*$/u;
|
|
23
|
+
/** 語を空白で区切る言語で、語に接していれば長い語の一部(114A The の A、Content-Type の Content)。 */
|
|
24
|
+
const JOINED = /[\p{L}\p{N}-]/u;
|
|
25
|
+
|
|
26
|
+
const isWord = (token: Token): boolean => LETTER.test(token.surface) && !REPEATABLE.has(token.pos) && token.features?.["NumType"] !== "Card";
|
|
27
|
+
|
|
28
|
+
const isDeterminer = (token: Token): boolean => token.features?.["PronType"] === "Art" || token.features?.["Poss"] === "Yes";
|
|
29
|
+
|
|
30
|
+
const sameWord = (first: Token, second: Token): boolean => first.surface.toLowerCase() === second.surface.toLowerCase() && first.pos === second.pos;
|
|
31
|
+
|
|
32
|
+
/** 二語のあいだ。space は空白だけ、markup は印だけ(文字も数字も無い)、text はそれ以外で、別々の語。 */
|
|
33
|
+
export type Gap = "space" | "markup" | "text";
|
|
34
|
+
|
|
35
|
+
export const gapBetween = (source: string, first: Token, second: Token): Gap => {
|
|
36
|
+
const between = source.slice(first.span.end, second.span.start);
|
|
37
|
+
if (SPACE_ONLY.test(between)) return "space";
|
|
38
|
+
return LETTER_OR_DIGIT.test(between) ? "text" : "markup";
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 語を空白で区切る言語だけ。日本語は語どうしが接しているのが普通。 */
|
|
42
|
+
export const isPartOfLongerWord = (source: string, first: Token, second: Token): boolean =>
|
|
43
|
+
JOINED.test(source.charAt(first.span.start - 1)) || JOINED.test(source.charAt(second.span.end));
|
|
44
|
+
|
|
45
|
+
const surfacesOf = (tokens: readonly Token[]): string => tokens.map((token) => token.surface.toLowerCase()).join("\u0000");
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 語彙表の行(had had、sign in in、set up up to)の k 語目と k+1 語目を重なりに重ねて、行の語が全部並んでいるか。
|
|
49
|
+
* 一語の行は重なりを言っていないので、重ねる位置が無い。
|
|
50
|
+
*/
|
|
51
|
+
const coversPairAt = (tokens: readonly Token[], at: number, words: readonly Token[]): boolean =>
|
|
52
|
+
Array.from({ length: words.length - 1 }, (_, k) => tokens.slice(at - k, at - k + words.length)).some((row) => surfacesOf(row) === surfacesOf(words));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* tokens の at 語目と次の語の重なりを、語彙表のどれかの行がそのまま含むか。
|
|
56
|
+
* 語彙表の語は、文と同じ解析器で分けたもの(entry.tokens)で比べる。書き方の空白の有無に左右されない。
|
|
57
|
+
*/
|
|
58
|
+
export const isAllowed = (tokens: readonly Token[], at: number, allowed: Lexicon): boolean =>
|
|
59
|
+
allowed.some((entry) => coversPairAt(tokens, at, entry.tokens ?? []));
|
|
60
|
+
|
|
61
|
+
export type Doubled = { readonly first: Token; readonly second: Token };
|
|
62
|
+
|
|
63
|
+
const LOWER_START = /^\p{Ll}/u;
|
|
64
|
+
const UPPER_START = /^\p{Lu}/u;
|
|
65
|
+
|
|
66
|
+
/** 小文字の語の後ろの大文字は、題名や名前の書き出し(the [Your Rights section])。同じ語の the The は書き損じのまま。 */
|
|
67
|
+
export const startsTitle = (first: Token, second: Token): boolean => LOWER_START.test(first.surface) && UPPER_START.test(second.surface);
|
|
68
|
+
|
|
69
|
+
/** アダプタが重ね言葉(UD の Echo=Rdp)と読んだ語。「会社会社で」の二つ目。 */
|
|
70
|
+
const isEcho = (token: Token): boolean => token.features?.["Echo"] === "Rdp";
|
|
71
|
+
|
|
72
|
+
const isDeterminerPair = (first: Token, second: Token): boolean => isDeterminer(first) && isDeterminer(second) && !startsTitle(first, second);
|
|
73
|
+
|
|
74
|
+
const isDoubled = (first: Token, second: Token): boolean =>
|
|
75
|
+
isWord(first) && isWord(second) && !isEcho(second) && (sameWord(first, second) || isDeterminerPair(first, second));
|
|
76
|
+
|
|
77
|
+
/** 並んだ二語が書き損じか。source は文書全体で、token の span もその座標。 */
|
|
78
|
+
export const doubledAt = (source: string, first: Token, second: Token, spaced: boolean): boolean => {
|
|
79
|
+
if (!isDoubled(first, second)) return false;
|
|
80
|
+
if (spaced && isPartOfLongerWord(source, first, second)) return false;
|
|
81
|
+
const gap = gapBetween(source, first, second);
|
|
82
|
+
return gap === "space" || (gap === "markup" && FUNCTION_WORD.has(first.pos));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 文の途中で、大文字の語の後ろに小文字の同じ語が来たら、一つ目は名前(Payment for May may be delayed)。
|
|
87
|
+
* 文頭の The the と、冠詞・所有の語(review The the draft)は名前にならないので書き損じ。
|
|
88
|
+
*/
|
|
89
|
+
export const isNameBefore = (first: Token, second: Token, opensSentence: boolean): boolean =>
|
|
90
|
+
!opensSentence && !isDeterminer(first) && UPPER_START.test(first.surface) && LOWER_START.test(second.surface);
|
|
91
|
+
|
|
92
|
+
const opensSentence = (tokens: readonly Token[], at: number): boolean => !tokens.slice(0, at).some((token) => LETTER.test(token.surface));
|
|
93
|
+
|
|
94
|
+
const repeatsPrevious = (tokens: readonly Token[], at: number): boolean => {
|
|
95
|
+
const [previous, token] = [tokens[at - 1], tokens[at]];
|
|
96
|
+
return previous !== undefined && token !== undefined && surfacesOf([previous]) === surfacesOf([token]);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** 語彙表の重なり(had had)も、三つ続けば(had had had)書き損じ。 */
|
|
100
|
+
const allowedAt = (tokens: readonly Token[], at: number, allowed: Lexicon): Lexicon => (repeatsPrevious(tokens, at) ? [] : allowed);
|
|
101
|
+
|
|
102
|
+
export const doubledIn = (source: string, tokens: readonly Token[], spaced: boolean, allowed: Lexicon): Doubled[] =>
|
|
103
|
+
tokens.flatMap((second, index) => {
|
|
104
|
+
const first = tokens[index - 1];
|
|
105
|
+
if (first === undefined || !doubledAt(source, first, second, spaced)) return [];
|
|
106
|
+
if (isAllowed(tokens, index - 1, allowedAt(tokens, index - 1, allowed))) return [];
|
|
107
|
+
return isNameBefore(first, second, opensSentence(tokens, index - 1)) ? [] : [{ first, second }];
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** 印を挟んでいても(the [the)、見せるのは二語だけ。 */
|
|
111
|
+
const wordOf = (doubled: Doubled, spaced: boolean): string => [doubled.first.surface, doubled.second.surface].join(spaced ? " " : "");
|
|
112
|
+
|
|
113
|
+
const findingOf = (sentence: Sentence, doubled: Doubled, spaced: boolean): Finding => ({
|
|
114
|
+
rule: "doubled-word",
|
|
115
|
+
severity: "warning",
|
|
116
|
+
line: 0,
|
|
117
|
+
column: 0,
|
|
118
|
+
quote: sentence.text.trim(),
|
|
119
|
+
values: { word: wordOf(doubled, spaced), offset: doubled.second.span.start },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const doubledWord: Detector = (doc, options): Finding[] => {
|
|
123
|
+
const spaced = doc.lengthUnit === "word";
|
|
124
|
+
const allowed = options.lexicon ?? [];
|
|
125
|
+
return doc.sentences.flatMap((sentence) =>
|
|
126
|
+
doubledIn(doc.source, sentence.tokens ?? [], spaced, allowed).map((doubled) => findingOf(sentence, doubled, spaced)),
|
|
127
|
+
);
|
|
128
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lengthOf } from "../measure.ts";
|
|
2
|
-
import
|
|
2
|
+
import { handsOver } from "./lead-in.ts";
|
|
3
|
+
import type { Detector, Finding, LengthUnit, ProseDocument, Section } from "../plugin.ts";
|
|
3
4
|
|
|
4
5
|
/** 見出しが短すぎると、偶然の一致で 100% になる。これ未満の見出しは見ない。 */
|
|
5
6
|
const MIN_GRAMS = 4;
|
|
@@ -51,9 +52,17 @@ const addsLittle = (section: Section, unit: LengthUnit): boolean => {
|
|
|
51
52
|
return lengthOf(first, unit) - headingUnits(section.heading, unit) <= NEW_MATERIAL[unit];
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
/** 最初の文が、同じ節の後ろ(箇条書き・表・コード)へ読者を渡している。 */
|
|
56
|
+
const leadsIn = (doc: ProseDocument, section: Section, phrases: readonly string[]): boolean => {
|
|
57
|
+
const first = section.firstSentence;
|
|
58
|
+
return first !== undefined && handsOver(first.text, doc.source.slice(first.span.end, section.span.end), phrases);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const headingEcho: Detector = (doc, options): Finding[] => {
|
|
62
|
+
const leadIns = (doc.lexicons["lead-in"] ?? []).map((entry) => entry.pattern);
|
|
63
|
+
return doc.sections
|
|
56
64
|
.filter((section) => section.heading.length > 0 && section.firstSentence !== undefined && addsLittle(section, doc.lengthUnit))
|
|
65
|
+
.filter((section) => !leadsIn(doc, section, leadIns))
|
|
57
66
|
.map((section) => ({ section, overlap: Math.round(containment(trigrams(section.heading), trigrams(section.firstSentence?.text ?? "")) * 100) }))
|
|
58
67
|
.filter(({ overlap }) => overlap >= options.limit)
|
|
59
68
|
.map(({ section, overlap }) => ({
|
|
@@ -64,3 +73,4 @@ export const headingEcho: Detector = (doc, options): Finding[] =>
|
|
|
64
73
|
quote: section.firstSentence?.text.trim() ?? "",
|
|
65
74
|
values: { heading: section.heading, count: overlap, limit: options.limit, offset: section.firstSentence?.span.start ?? section.span.start },
|
|
66
75
|
}));
|
|
76
|
+
};
|
package/src/detectors/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { repeatedHead } from "./repeated-head.ts";
|
|
|
6
6
|
import { sentenceRhythm } from "./sentence-rhythm.ts";
|
|
7
7
|
import { phraseMatch } from "./phrase-match.ts";
|
|
8
8
|
import { agentlessPassive } from "./agentless-passive.ts";
|
|
9
|
+
import { doubledWord } from "./doubled-word.ts";
|
|
9
10
|
import { sentenceEnding } from "./sentence-ending.ts";
|
|
10
11
|
import { doubledParticle, nounEnding } from "./token-shape.ts";
|
|
11
12
|
import { kanjiRun, middleDot } from "./char-shape.ts";
|
|
@@ -29,6 +30,7 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
|
|
|
29
30
|
"sentence-ending": sentenceEnding,
|
|
30
31
|
"noun-ending": nounEnding,
|
|
31
32
|
"doubled-particle": doubledParticle,
|
|
33
|
+
"doubled-word": doubledWord,
|
|
32
34
|
"kanji-run": kanjiRun,
|
|
33
35
|
"middle-dot": middleDot,
|
|
34
36
|
"adverb-density": adverbDensity,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 文末のコロン。後ろに強調の印(**・_)や空白が残っていても見る。 */
|
|
2
|
+
const ENDS_WITH_COLON = /[::][\s*_]*$/u;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 後ろの箇条書きや表へ読者を渡す文(「次のとおりとする。」「The following expenses require receipts:」)。
|
|
6
|
+
* 見出しの語を繰り返していても、中身は後ろにある。言い回しは言語パッケージの語彙表 lead-in が持つ。
|
|
7
|
+
*/
|
|
8
|
+
export const isLeadIn = (sentence: string, phrases: readonly string[]): boolean => {
|
|
9
|
+
const text = sentence.trim().toLowerCase();
|
|
10
|
+
return ENDS_WITH_COLON.test(text) || phrases.some((phrase) => phrase !== "" && text.includes(phrase.toLowerCase()));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** 渡す先(following: 同じ節の、その文より後ろ)が空なら、渡す文ではなくただの繰り返し。 */
|
|
14
|
+
export const handsOver = (sentence: string, following: string, phrases: readonly string[]): boolean => following.trim() !== "" && isLeadIn(sentence, phrases);
|
|
@@ -32,6 +32,16 @@ export const entryIn = (sentence: Sentence, entry: LexiconEntry): boolean => {
|
|
|
32
32
|
return tokens.some((_token, start) => runsAt(tokens, words, start));
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
export type TokenRange = { readonly start: number; readonly end: number };
|
|
36
|
+
|
|
37
|
+
/** 語彙の語が文のどの語の並びに当たったか(始まりと、終わった次の位置)。品詞が無ければ位置を言えないので空。 */
|
|
38
|
+
export const entryRanges = (sentence: Sentence, entry: LexiconEntry): TokenRange[] => {
|
|
39
|
+
const tokens = sentence.tokens;
|
|
40
|
+
const words = entry.tokens;
|
|
41
|
+
if (tokens === undefined || words === undefined || words.length === 0) return [];
|
|
42
|
+
return tokens.flatMap((_token, start) => (runsAt(tokens, words, start) ? [{ start, end: start + words.length }] : []));
|
|
43
|
+
};
|
|
44
|
+
|
|
35
45
|
const LEADING_MARK = new Set(["PUNCT", "SYM"]);
|
|
36
46
|
|
|
37
47
|
/** 語彙の語で文が始まるか。文頭の記号は飛ばす。品詞が無ければ文字列の前方一致。 */
|