chaffjs 0.16.0 → 0.17.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/calendar-number.d.ts +7 -2
- package/dist/calendar-number.d.ts.map +1 -1
- package/dist/calendar-number.js +10 -3
- package/dist/calendar-number.js.map +1 -1
- package/dist/closing-numbers.d.ts +20 -0
- package/dist/closing-numbers.d.ts.map +1 -0
- package/dist/closing-numbers.js +56 -0
- package/dist/closing-numbers.js.map +1 -0
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +11 -9
- package/dist/commands/test.js.map +1 -1
- package/dist/detectors/acronym-context.d.ts +2 -0
- package/dist/detectors/acronym-context.d.ts.map +1 -1
- package/dist/detectors/acronym-context.js +13 -0
- package/dist/detectors/acronym-context.js.map +1 -1
- package/dist/detectors/acronym-expansion.d.ts +2 -0
- package/dist/detectors/acronym-expansion.d.ts.map +1 -1
- package/dist/detectors/acronym-expansion.js +104 -1
- package/dist/detectors/acronym-expansion.js.map +1 -1
- package/dist/detectors/agreement-slip.d.ts +32 -0
- package/dist/detectors/agreement-slip.d.ts.map +1 -0
- package/dist/detectors/agreement-slip.js +137 -0
- package/dist/detectors/agreement-slip.js.map +1 -0
- package/dist/detectors/content-morphemes.d.ts +4 -0
- package/dist/detectors/content-morphemes.d.ts.map +1 -0
- package/dist/detectors/content-morphemes.js +12 -0
- package/dist/detectors/content-morphemes.js.map +1 -0
- package/dist/detectors/en-shape.d.ts +0 -1
- package/dist/detectors/en-shape.d.ts.map +1 -1
- package/dist/detectors/en-shape.js +0 -235
- package/dist/detectors/en-shape.js.map +1 -1
- package/dist/detectors/heading-echo.d.ts.map +1 -1
- package/dist/detectors/heading-echo.js +12 -18
- package/dist/detectors/heading-echo.js.map +1 -1
- package/dist/detectors/heading-overlap.d.ts +13 -0
- package/dist/detectors/heading-overlap.d.ts.map +1 -0
- package/dist/detectors/heading-overlap.js +52 -0
- package/dist/detectors/heading-overlap.js.map +1 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +6 -1
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/letter-joined-name.d.ts +13 -0
- package/dist/detectors/letter-joined-name.d.ts.map +1 -0
- package/dist/detectors/letter-joined-name.js +11 -0
- package/dist/detectors/letter-joined-name.js.map +1 -0
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +5 -2
- package/dist/detectors/lexicon.js.map +1 -1
- package/dist/detectors/orthography.js +2 -2
- package/dist/detectors/orthography.js.map +1 -1
- package/dist/detectors/oxford-comma.d.ts +3 -0
- package/dist/detectors/oxford-comma.d.ts.map +1 -0
- package/dist/detectors/oxford-comma.js +340 -0
- package/dist/detectors/oxford-comma.js.map +1 -0
- package/dist/detectors/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +29 -10
- package/dist/detectors/signals.js.map +1 -1
- package/dist/detectors/stacked-hedge.d.ts +0 -7
- package/dist/detectors/stacked-hedge.d.ts.map +1 -1
- package/dist/detectors/stacked-hedge.js +6 -3
- package/dist/detectors/stacked-hedge.js.map +1 -1
- package/dist/detectors/stray-space.d.ts +7 -0
- package/dist/detectors/stray-space.d.ts.map +1 -0
- package/dist/detectors/stray-space.js +34 -0
- package/dist/detectors/stray-space.js.map +1 -0
- package/dist/detectors/structure.d.ts +3 -1
- package/dist/detectors/structure.d.ts.map +1 -1
- package/dist/detectors/structure.js +4 -2
- package/dist/detectors/structure.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +15 -3
- package/dist/document.js.map +1 -1
- package/dist/genre-parse.d.ts +5 -1
- package/dist/genre-parse.d.ts.map +1 -1
- package/dist/genre-parse.js +16 -3
- package/dist/genre-parse.js.map +1 -1
- package/dist/genre-suggest.d.ts.map +1 -1
- package/dist/genre-suggest.js +15 -1
- package/dist/genre-suggest.js.map +1 -1
- package/dist/line-paragraphs.d.ts.map +1 -1
- package/dist/line-paragraphs.js +3 -1
- package/dist/line-paragraphs.js.map +1 -1
- package/dist/mask.d.ts.map +1 -1
- package/dist/mask.js +2 -11
- package/dist/mask.js.map +1 -1
- package/dist/number-name.js +1 -1
- package/dist/number-name.js.map +1 -1
- package/dist/opening-line.d.ts +3 -0
- package/dist/opening-line.d.ts.map +1 -0
- package/dist/opening-line.js +14 -0
- package/dist/opening-line.js.map +1 -0
- package/dist/orthography.d.ts +5 -1
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +7 -1
- package/dist/orthography.js.map +1 -1
- package/dist/paragraph-length.d.ts +7 -0
- package/dist/paragraph-length.d.ts.map +1 -0
- package/dist/paragraph-length.js +10 -0
- package/dist/paragraph-length.js.map +1 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/quoted-span.d.ts +0 -2
- package/dist/quoted-span.d.ts.map +1 -1
- package/dist/quoted-span.js +1 -1
- package/dist/quoted-span.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +8 -0
- package/dist/rule-load.js.map +1 -1
- package/dist/run-semantic.d.ts +3 -1
- package/dist/run-semantic.d.ts.map +1 -1
- package/dist/run-semantic.js +8 -3
- package/dist/run-semantic.js.map +1 -1
- package/dist/run.d.ts +2 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +4 -1
- package/dist/run.js.map +1 -1
- package/dist/semantic.d.ts +1 -1
- package/dist/semantic.d.ts.map +1 -1
- package/dist/semantic.js +10 -3
- package/dist/semantic.js.map +1 -1
- package/dist/span-merge.d.ts +4 -0
- package/dist/span-merge.d.ts.map +1 -0
- package/dist/span-merge.js +12 -0
- package/dist/span-merge.js.map +1 -0
- package/dist/speaker-labels.d.ts +4 -0
- package/dist/speaker-labels.d.ts.map +1 -0
- package/dist/speaker-labels.js +106 -0
- package/dist/speaker-labels.js.map +1 -0
- package/dist/stray-space.d.ts +17 -0
- package/dist/stray-space.d.ts.map +1 -0
- package/dist/stray-space.js +66 -0
- package/dist/stray-space.js.map +1 -0
- package/dist/structure/relative-resolve.d.ts.map +1 -1
- package/dist/structure/relative-resolve.js +26 -20
- package/dist/structure/relative-resolve.js.map +1 -1
- package/dist/team-names.d.ts.map +1 -1
- package/dist/team-names.js +11 -14
- package/dist/team-names.js.map +1 -1
- package/genres.yaml +34 -2
- package/package.json +3 -3
- package/rules/agreement-slip.yaml +36 -0
- package/rules/empty-conclusion.yaml +1 -0
- package/rules/heading-echo.yaml +1 -0
- package/rules/max-paragraph-length.yaml +9 -2
- package/rules/max-sentence-length.yaml +4 -0
- package/rules/oxford-comma-consistency.yaml +1 -1
- package/rules/stray-space.yaml +38 -0
- package/rules/undefined-acronym.yaml +1 -0
- package/src/calendar-number.ts +16 -7
- package/src/closing-numbers.ts +72 -0
- package/src/commands/test.ts +19 -14
- package/src/detectors/acronym-context.ts +16 -0
- package/src/detectors/acronym-expansion.ts +132 -1
- package/src/detectors/agreement-slip.ts +191 -0
- package/src/detectors/content-morphemes.ts +19 -0
- package/src/detectors/en-shape.ts +0 -269
- package/src/detectors/heading-echo.ts +12 -20
- package/src/detectors/heading-overlap.ts +65 -0
- package/src/detectors/index.ts +6 -1
- package/src/detectors/letter-joined-name.ts +15 -0
- package/src/detectors/lexicon.ts +6 -2
- package/src/detectors/orthography.ts +2 -2
- package/src/detectors/oxford-comma.ts +402 -0
- package/src/detectors/signals.ts +32 -9
- package/src/detectors/stacked-hedge.ts +6 -3
- package/src/detectors/stray-space.ts +40 -0
- package/src/detectors/structure.ts +10 -2
- package/src/document.ts +25 -4
- package/src/genre-parse.ts +30 -5
- package/src/genre-suggest.ts +15 -2
- package/src/line-paragraphs.ts +4 -1
- package/src/mask.ts +2 -11
- package/src/number-name.ts +1 -1
- package/src/opening-line.ts +17 -0
- package/src/orthography.ts +10 -2
- package/src/paragraph-length.ts +9 -0
- package/src/plugin.ts +6 -0
- package/src/quoted-span.ts +1 -1
- package/src/rule-load.ts +8 -0
- package/src/run-semantic.ts +28 -20
- package/src/run.ts +5 -1
- package/src/semantic.ts +11 -3
- package/src/span-merge.ts +13 -0
- package/src/speaker-labels.ts +129 -0
- package/src/stray-space.ts +81 -0
- package/src/structure/relative-resolve.ts +34 -19
- package/src/team-names.ts +13 -13
package/src/document.ts
CHANGED
|
@@ -9,12 +9,13 @@ import { maskSpans } from "./mask.ts";
|
|
|
9
9
|
import { spansWithin, unmaskedSoftBreaks } from "./soft-break.ts";
|
|
10
10
|
import { segmentJoined } from "./joined-view.ts";
|
|
11
11
|
import { lineParagraphs } from "./line-paragraphs.ts";
|
|
12
|
+
import { speakerLabels } from "./speaker-labels.ts";
|
|
12
13
|
import { buildTree, type Outline } from "./structure/build.ts";
|
|
13
14
|
import { isMarkdownPath } from "./structure/markdown-path.ts";
|
|
14
15
|
import { pageFurniture, textOutline } from "./page-furniture.ts";
|
|
15
16
|
import { tokenizedLexicons } from "./lexicon-tokens.ts";
|
|
16
17
|
import { inPageAnchors, isInPageNavigation, isNavigationList, type InPageAnchors } from "./in-page-nav.ts";
|
|
17
|
-
import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile } from "./plugin.ts";
|
|
18
|
+
import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile, Token } from "./plugin.ts";
|
|
18
19
|
|
|
19
20
|
type Place = { readonly offset?: number | undefined };
|
|
20
21
|
type Node = {
|
|
@@ -204,7 +205,17 @@ const sentencesOf = (prose: string, paragraphs: readonly Span[], adapter: Langua
|
|
|
204
205
|
})),
|
|
205
206
|
);
|
|
206
207
|
|
|
207
|
-
|
|
208
|
+
/** 見出しの語。品詞を読んでいない文書(文が tokens を持たない)では分けない。 */
|
|
209
|
+
const headingTokensOf = (adapter: LanguageAdapter, tagged: boolean, heading: string): { headingTokens?: readonly Token[] } =>
|
|
210
|
+
tagged && heading !== "" ? { headingTokens: adapter.segment(heading).sentences.flatMap((sentence) => sentence.tokens ?? []) } : {};
|
|
211
|
+
|
|
212
|
+
const sectionsOf = (
|
|
213
|
+
headings: readonly Heading[],
|
|
214
|
+
sentences: readonly Sentence[],
|
|
215
|
+
strongs: readonly Span[],
|
|
216
|
+
length: number,
|
|
217
|
+
tokensOf: (heading: string) => { headingTokens?: readonly Token[] },
|
|
218
|
+
): Section[] => {
|
|
208
219
|
const bounds = headings.map((heading, index) => ({ heading, from: heading.end, to: headings[index + 1]?.start ?? length }));
|
|
209
220
|
const lead = { heading: { depth: 0, text: "", start: 0, end: 0 }, from: 0, to: headings[0]?.start ?? length };
|
|
210
221
|
return [lead, ...bounds]
|
|
@@ -214,6 +225,7 @@ const sectionsOf = (headings: readonly Heading[], sentences: readonly Sentence[]
|
|
|
214
225
|
return {
|
|
215
226
|
depth: heading.depth,
|
|
216
227
|
heading: heading.text,
|
|
228
|
+
...tokensOf(heading.text),
|
|
217
229
|
span: { start: from, end: to },
|
|
218
230
|
sentences: inside,
|
|
219
231
|
strongCount: strongs.filter((span) => within(span, from, to)).length,
|
|
@@ -290,6 +302,15 @@ export const teamRules = (config: {
|
|
|
290
302
|
names: config.names ?? [],
|
|
291
303
|
});
|
|
292
304
|
|
|
305
|
+
/**
|
|
306
|
+
* masked を覆った本文から、話し手の名前(戯曲・議事録)も覆う。名前は誰が話すかの印で、文でも本文でもない。
|
|
307
|
+
* 名前は覆った後の本文で探す。コードや表の中の行頭を話し手と数えない。
|
|
308
|
+
*/
|
|
309
|
+
const proseOf = (source: string, masked: readonly Span[]): string => {
|
|
310
|
+
const unlabelled = maskSpans(source, masked);
|
|
311
|
+
return maskSpans(unlabelled, speakerLabels(unlabelled));
|
|
312
|
+
};
|
|
313
|
+
|
|
293
314
|
export const buildDocument = (
|
|
294
315
|
path: string,
|
|
295
316
|
source: string,
|
|
@@ -304,7 +325,7 @@ export const buildDocument = (
|
|
|
304
325
|
// テキストの文書は、ページのヘッダーとフッターも本文ではない(Markdown には改ページが無い)。
|
|
305
326
|
const blocks = [...collectMasks(root, source, anchors), ...(isMarkdownPath(path) ? [] : pageFurniture(source))];
|
|
306
327
|
const masked = [...blocks, ...emphasisSpans(root, source)];
|
|
307
|
-
const prose =
|
|
328
|
+
const prose = proseOf(source, masked);
|
|
308
329
|
// ページの案内は段落としても数えない。数えると、目次の行が「本題までの段落」に入る。
|
|
309
330
|
const paragraphSpans = spansOfType(root, "paragraph", (node) => !isInPageNavigation(node, anchors));
|
|
310
331
|
const listItems = spansOfType(root, "listItem");
|
|
@@ -327,7 +348,7 @@ export const buildDocument = (
|
|
|
327
348
|
language: adapter.id,
|
|
328
349
|
lengthUnit: adapter.capabilities.lengthUnit,
|
|
329
350
|
capabilities: adapter.capabilities,
|
|
330
|
-
sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root, blocks), source.length),
|
|
351
|
+
sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root, blocks), source.length, (heading) => headingTokensOf(adapter, tagged, heading)),
|
|
331
352
|
sentences,
|
|
332
353
|
listSpans: listItems,
|
|
333
354
|
paragraphs: paragraphsOf(prose, paragraphSpans, sentences, listItems),
|
package/src/genre-parse.ts
CHANGED
|
@@ -20,8 +20,17 @@ export type GenreDefinition = {
|
|
|
20
20
|
/** A line shape that marks the genre, and how many lines must have it. */
|
|
21
21
|
type LineCue = { readonly line: RegExp; readonly minLines: number };
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* What makes a document look like this genre when no genre is set: its path, or per language, the shape of the body's first
|
|
25
|
+
* line (a letter's salutation) or enough lines of the genre's own shape.
|
|
26
|
+
*/
|
|
27
|
+
export type GenreSuggest = {
|
|
28
|
+
readonly paths: readonly RegExp[];
|
|
29
|
+
readonly lines: Readonly<Record<string, LineCue>>;
|
|
30
|
+
readonly openings: Readonly<Record<string, RegExp>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type LanguageCue = { readonly line: LineCue | undefined; readonly opening: RegExp | undefined };
|
|
25
34
|
|
|
26
35
|
export type GenreData = { readonly groups: readonly GenreGroup[]; readonly genres: readonly GenreDefinition[] };
|
|
27
36
|
|
|
@@ -71,15 +80,31 @@ const lineCueOf = (value: unknown, where: string): LineCue => {
|
|
|
71
80
|
return { line: patternOf(value["line"], "u", where), minLines };
|
|
72
81
|
};
|
|
73
82
|
|
|
83
|
+
const hasLineCue = (value: Readonly<Record<string, unknown>>): boolean => value["line"] !== undefined || value["min_lines"] !== undefined;
|
|
84
|
+
|
|
85
|
+
const languageCueOf = (value: unknown, where: string): LanguageCue => {
|
|
86
|
+
if (!isRecord(value) || (!hasLineCue(value) && value["opening"] === undefined)) throw new Error(`${where}: needs line and min_lines, or opening`);
|
|
87
|
+
return {
|
|
88
|
+
line: hasLineCue(value) ? lineCueOf(value, where) : undefined,
|
|
89
|
+
opening: value["opening"] === undefined ? undefined : patternOf(value["opening"], "u", where),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const presentOf = <T>(cues: readonly (readonly [string, T | undefined])[]): Record<string, T> =>
|
|
94
|
+
Object.fromEntries(cues.filter((entry): entry is readonly [string, T] => entry[1] !== undefined));
|
|
95
|
+
|
|
74
96
|
const suggestOf = (value: unknown, where: string): GenreSuggest => {
|
|
75
|
-
if (value === undefined) return { paths: [], lines: {} };
|
|
97
|
+
if (value === undefined) return { paths: [], lines: {}, openings: {} };
|
|
76
98
|
if (!isRecord(value)) throw new Error(`${where}: suggest must be a map`);
|
|
77
99
|
const paths = value["paths"] ?? [];
|
|
78
100
|
if (!Array.isArray(paths)) throw new Error(`${where}: suggest.paths must be a list`);
|
|
79
|
-
const
|
|
101
|
+
const cues = Object.entries(value)
|
|
102
|
+
.filter(([key]) => key !== "paths")
|
|
103
|
+
.map(([language, cue]) => [language, languageCueOf(cue, `${where} suggest.${language}`)] as const);
|
|
80
104
|
return {
|
|
81
105
|
paths: paths.map((path) => patternOf(path, "iu", `${where} suggest.paths`)),
|
|
82
|
-
lines:
|
|
106
|
+
lines: presentOf(cues.map(([language, cue]) => [language, cue.line] as const)),
|
|
107
|
+
openings: presentOf(cues.map(([language, cue]) => [language, cue.opening] as const)),
|
|
83
108
|
};
|
|
84
109
|
};
|
|
85
110
|
|
package/src/genre-suggest.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GenreData, GenreDefinition } from "./genre-parse.ts";
|
|
2
|
+
import { openingLine } from "./opening-line.ts";
|
|
2
3
|
import { detected, type ProfileDefinition } from "./profile/parse.ts";
|
|
3
4
|
|
|
4
5
|
/** The file a genre is suggested for: where it is, what it says, and its language. */
|
|
@@ -7,6 +8,15 @@ export type SuggestInput = { readonly path: string; readonly source: string; rea
|
|
|
7
8
|
/** A path cue names the kind outright, so it outweighs any count of lines. */
|
|
8
9
|
const BY_PATH = Number.POSITIVE_INFINITY;
|
|
9
10
|
|
|
11
|
+
/** How the body opens (Dear ..., 拝啓) says what the document is even when it encloses another kind; a path still says more. */
|
|
12
|
+
const BY_OPENING = Number.MAX_VALUE;
|
|
13
|
+
|
|
14
|
+
const opensAs = (genre: GenreDefinition, input: SuggestInput): boolean => {
|
|
15
|
+
const opening = genre.suggest.openings[input.language];
|
|
16
|
+
const first = openingLine(input.source);
|
|
17
|
+
return opening !== undefined && first !== undefined && opening.test(first);
|
|
18
|
+
};
|
|
19
|
+
|
|
10
20
|
/** How many times over the genre's lines reach their minimum: 1 is just enough, 0 is none of that shape. */
|
|
11
21
|
const linesScore = (genre: GenreDefinition, input: SuggestInput): number => {
|
|
12
22
|
const cue = genre.suggest.lines[input.language];
|
|
@@ -20,8 +30,11 @@ const profileScore = (genre: GenreDefinition, input: SuggestInput, profiles: rea
|
|
|
20
30
|
return profile !== undefined && detected(profile, input.source, input.language) ? 1 : 0;
|
|
21
31
|
};
|
|
22
32
|
|
|
23
|
-
const scoreOf = (genre: GenreDefinition, input: SuggestInput, profiles: readonly ProfileDefinition[]): number =>
|
|
24
|
-
genre.suggest.paths.some((pattern) => pattern.test(input.path))
|
|
33
|
+
const scoreOf = (genre: GenreDefinition, input: SuggestInput, profiles: readonly ProfileDefinition[]): number => {
|
|
34
|
+
if (genre.suggest.paths.some((pattern) => pattern.test(input.path))) return BY_PATH;
|
|
35
|
+
if (opensAs(genre, input)) return BY_OPENING;
|
|
36
|
+
return Math.max(linesScore(genre, input), profileScore(genre, input, profiles));
|
|
37
|
+
};
|
|
25
38
|
|
|
26
39
|
type Scored = { readonly genre: GenreDefinition; readonly score: number };
|
|
27
40
|
|
package/src/line-paragraphs.ts
CHANGED
|
@@ -51,6 +51,9 @@ const firstStartingAt = (cores: readonly Span[], offset: number): number => {
|
|
|
51
51
|
/** 改行をまたぐ文が無い。またぐなら、改行の直前に始まった文がまだ続いている。 */
|
|
52
52
|
const endsSentence = (line: Line, cores: readonly Span[]): boolean => (cores[firstStartingAt(cores, line.newline) - 1]?.end ?? 0) <= line.newline;
|
|
53
53
|
|
|
54
|
+
/** 覆われて空白だけになった行(話し手の名前の行)は段落を終えない。終えると、文の無い段落ができる。 */
|
|
55
|
+
const hasText = (source: string, line: Line): boolean => source.slice(line.span.start, line.span.end).trim().length > 0;
|
|
56
|
+
|
|
54
57
|
/** 行の並びを、文の終わる改行で区切った段落。 */
|
|
55
58
|
const groupAt = (lines: readonly Line[], closing: readonly boolean[]): Span[] => {
|
|
56
59
|
const firsts = lines.flatMap((_, index) => (index === 0 || closing[index - 1] === true ? [index] : []));
|
|
@@ -77,7 +80,7 @@ const isLineShaped = (lines: readonly Line[], closing: readonly boolean[], group
|
|
|
77
80
|
export const lineParagraphs = (source: string, paragraph: Span, sentences: readonly Span[]): Span[] => {
|
|
78
81
|
const lines = linesOf(source, paragraph);
|
|
79
82
|
const cores = sentences.map((sentence) => coreOf(source, sentence)).filter((core) => core.end > core.start);
|
|
80
|
-
const closing = lines.map((line) => endsSentence(line, cores));
|
|
83
|
+
const closing = lines.map((line) => hasText(source, line) && endsSentence(line, cores));
|
|
81
84
|
const groups = groupAt(lines, closing);
|
|
82
85
|
return isLineShaped(lines, closing, groups, cores) ? groups : [paragraph];
|
|
83
86
|
};
|
package/src/mask.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Span } from "./plugin.ts";
|
|
2
|
+
import { mergeSpans } from "./span-merge.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* 非 prose(コードブロック・インラインコード・表・見出し・URL)を、
|
|
@@ -16,18 +17,8 @@ import type { Span } from "./plugin.ts";
|
|
|
16
17
|
*/
|
|
17
18
|
const blankOut = (text: string): string => text.replace(/[^\n]/gu, (char) => " ".repeat(char.length));
|
|
18
19
|
|
|
19
|
-
const merge = (spans: readonly Span[]): Span[] =>
|
|
20
|
-
spans
|
|
21
|
-
.toSorted((left, right) => left.start - right.start)
|
|
22
|
-
.reduce<Span[]>((acc, span) => {
|
|
23
|
-
const last = acc.at(-1);
|
|
24
|
-
if (last !== undefined && span.start <= last.end) acc[acc.length - 1] = { start: last.start, end: Math.max(last.end, span.end) };
|
|
25
|
-
else acc.push(span);
|
|
26
|
-
return acc;
|
|
27
|
-
}, []);
|
|
28
|
-
|
|
29
20
|
export const maskSpans = (source: string, spans: readonly Span[]): string => {
|
|
30
|
-
const merged =
|
|
21
|
+
const merged = mergeSpans(spans, true);
|
|
31
22
|
const { parts, cursor } = merged.reduce<{ parts: string[]; cursor: number }>(
|
|
32
23
|
(acc, span) => ({
|
|
33
24
|
parts: [...acc.parts, source.slice(acc.cursor, span.start), blankOut(source.slice(span.start, span.end))],
|
package/src/number-name.ts
CHANGED
|
@@ -88,7 +88,7 @@ export const placeChainBefore = (tokens: readonly Token[], end: number): Token[]
|
|
|
88
88
|
* ハイフンの無い数(千代田区23 番)も読まない。topUnits は都道府県の単位(語彙表 prefecture-unit)。
|
|
89
89
|
*/
|
|
90
90
|
const isAddressNumber = (text: string, run: Span, tokens: readonly Token[], base: number, topUnits: ReadonlySet<string>): boolean =>
|
|
91
|
-
|
|
91
|
+
Array.from(text.slice(run.start, run.end)).some(isHyphen) &&
|
|
92
92
|
placeChainBefore(tokens, base + run.start).some((token) => isGeoUnit(token) && !topUnits.has(token.surface));
|
|
93
93
|
|
|
94
94
|
/** ハイフンでつないだ識別子か、番号の立つ位置の番号か、文頭の節番号か。 */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { plainSource } from "./plain-source.ts";
|
|
2
|
+
|
|
3
|
+
const FRONT_MATTER_FENCE = "---";
|
|
4
|
+
/** An ATX heading; four spaces of indent make it a code line instead. */
|
|
5
|
+
const HEADING = /^ {0,3}#{1,6}(?:\s|$)/u;
|
|
6
|
+
|
|
7
|
+
/** The lines after a leading front matter block. */
|
|
8
|
+
const bodyLines = (lines: readonly string[]): readonly string[] => {
|
|
9
|
+
const close = lines[0] === FRONT_MATTER_FENCE ? lines.indexOf(FRONT_MATTER_FENCE, 1) : -1;
|
|
10
|
+
return close === -1 ? lines : lines.slice(close + 1);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** The line a document's body opens with (a letter's salutation), past front matter, blank lines and Markdown headings. */
|
|
14
|
+
export const openingLine = (source: string): string | undefined =>
|
|
15
|
+
bodyLines(plainSource(source).split("\n"))
|
|
16
|
+
.find((line) => line.trim() !== "" && !HEADING.test(line))
|
|
17
|
+
?.trim();
|
package/src/orthography.ts
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* 字に挟まれた ’ と、どこにあっても ʼ を ' にする(don’t → don't)。語彙表は ' で書くので、’ で書いた本文も同じ語に当てるため。
|
|
7
|
+
* 英語アダプタの解析器が同じ決めかたをする(chaff とアダプタは実行時の値を共有しないので、ここにも置く)。一字を一字に置き換えるので位置は変わらない。
|
|
8
|
+
*/
|
|
9
|
+
const APOSTROPHE = /(?<=[\p{L}\p{N}])’(?=\p{L})|ʼ/gu;
|
|
10
|
+
|
|
11
|
+
export const straightApostrophes = (text: string): string => text.replace(APOSTROPHE, "'");
|
|
12
|
+
|
|
5
13
|
/** text の中で word が現れる [始まり, 終わり) を、左から順に。大文字小文字を区別しないときは i で探す(位置は元の文字列のまま)。 */
|
|
6
14
|
const spansOf = (text: string, word: string, ignoreCase: boolean): [number, number][] => {
|
|
7
15
|
const pattern = new RegExp(escapeRegExp(word), ignoreCase ? "giu" : "gu");
|
|
@@ -55,7 +63,7 @@ const kindOf = (char: string | undefined, digitSide: "before-digit" | "after-dig
|
|
|
55
63
|
return DIGIT.test(char) ? digitSide : undefined;
|
|
56
64
|
};
|
|
57
65
|
|
|
58
|
-
const isJapanese = (char: string | undefined): boolean => char !== undefined && JAPANESE.test(char);
|
|
66
|
+
export const isJapanese = (char: string | undefined): boolean => char !== undefined && JAPANESE.test(char);
|
|
59
67
|
|
|
60
68
|
/** japanese が日本語の字のとき、other の英字・数字の種類。 */
|
|
61
69
|
const latinBeside = (japanese: string | undefined, other: string | undefined, digitSide: "before-digit" | "after-digit"): SpacingKind | undefined =>
|
|
@@ -189,7 +197,7 @@ export const latinBoundaries = (text: string, written: string = text): Boundary[
|
|
|
189
197
|
* 混ざっているとき、そろえるべき少数派。種類(英字・数字の前・数字の後ろ)ごとに数える。
|
|
190
198
|
* 同数なら、文書が先に使った書き方をその文書の書き方とみなし、後から出た書き方を少数派にする。
|
|
191
199
|
*/
|
|
192
|
-
export const minorityStyle = (boundaries: readonly
|
|
200
|
+
export const minorityStyle = (boundaries: readonly { readonly spaced: boolean }[]): boolean | undefined => {
|
|
193
201
|
const spaced = boundaries.filter((boundary) => boundary.spaced).length;
|
|
194
202
|
const touching = boundaries.length - spaced;
|
|
195
203
|
if (spaced === 0 || touching === 0) return undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 段落が長すぎるか。文の数が上限を超え、しかも長さが「上限の数だけ普通の長さの文を並べた長さ」を超えるときだけ。
|
|
3
|
+
* 短い文をいくつ並べても、読み手が息をつけない壁にはならない。長さは adapter の単位(日本語は文字、英語は語)。
|
|
4
|
+
* fullSentence が無ければ、文の数だけで決める。
|
|
5
|
+
*/
|
|
6
|
+
export const isTooLongParagraph = (sentenceSizes: readonly number[], limit: number, fullSentence: number | undefined): boolean => {
|
|
7
|
+
const size = sentenceSizes.reduce((sum, sentence) => sum + sentence, 0);
|
|
8
|
+
return sentenceSizes.length > limit && (fullSentence === undefined || size > limit * fullSentence);
|
|
9
|
+
};
|
package/src/plugin.ts
CHANGED
|
@@ -204,6 +204,8 @@ export type Section = {
|
|
|
204
204
|
/** 見出しの深さ。見出しより前の導入部は 0。 */
|
|
205
205
|
readonly depth: number;
|
|
206
206
|
readonly heading: string;
|
|
207
|
+
/** 見出しを文と同じ解析器で語に分けたもの。文が tokens を持つときだけ入る。 */
|
|
208
|
+
readonly headingTokens?: readonly Token[];
|
|
207
209
|
readonly span: Span;
|
|
208
210
|
readonly sentences: readonly Sentence[];
|
|
209
211
|
/** この節に含まれる強調(Markdown の ** **)の数。 */
|
|
@@ -346,6 +348,8 @@ export type DetectorOptions = {
|
|
|
346
348
|
readonly lexicon?: Lexicon | undefined;
|
|
347
349
|
/** 見る範囲。opening は冒頭 2 段落、closing は最後の節、whole は全体。 */
|
|
348
350
|
readonly where?: string | undefined;
|
|
351
|
+
/** 普通の長さの文 1 つの長さ(adapter の単位)。段落の長さを文の数と一緒に見る rule だけが持つ。 */
|
|
352
|
+
readonly fullSentence?: number | undefined;
|
|
349
353
|
};
|
|
350
354
|
|
|
351
355
|
export type Detector = (doc: ProseDocument, options: DetectorOptions) => Finding[];
|
|
@@ -378,6 +382,8 @@ export type RuleDefinition = {
|
|
|
378
382
|
/** L4 のみ。LLM に渡す決まり。言語別。 */
|
|
379
383
|
readonly what_to_check: Localized | undefined;
|
|
380
384
|
readonly where: string | undefined;
|
|
385
|
+
/** 普通の長さの文 1 つの長さ。言語で単位が違うので、levels と同じく読み込み時に言語で平坦化済み。 */
|
|
386
|
+
readonly full_sentence: number | undefined;
|
|
381
387
|
/** adapter に要る capability。"pos" / "lemma"。満たさなければ動かさない。spec §16。 */
|
|
382
388
|
readonly requires: readonly string[];
|
|
383
389
|
/** 使えるなら用意してほしい capability("pos")。requires と違い、満たせなくても動かす(品詞が無ければ文字だけで見る)。 */
|
package/src/quoted-span.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Sentence, Span } from "./plugin.ts";
|
|
|
4
4
|
type Marks = Readonly<Record<string, string>>;
|
|
5
5
|
|
|
6
6
|
/** 鉤括弧。日本語の引用と題名。 */
|
|
7
|
-
|
|
7
|
+
const KAGI_MARKS: Marks = { "「": "」", "『": "』" };
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* 引用符も含めた、人の言葉を引く印。英語の "…" は開きと閉じが同じ字なので、開いている間に来たら閉じと読む。
|
package/src/rule-load.ts
CHANGED
|
@@ -83,6 +83,13 @@ const severityOf = (raw: unknown, language: string): Severity => {
|
|
|
83
83
|
return isSeverity(picked) ? picked : "warning";
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
/** 言語で単位が違う数(日本語は文字、英語は語)。その言語のぶんだけを取る。無い言語では undefined。 */
|
|
87
|
+
const numberFor = (raw: unknown, language: string): number | undefined => {
|
|
88
|
+
if (typeof raw === "number") return raw;
|
|
89
|
+
const picked: unknown = isRecord(raw) ? (raw[language] ?? raw["default"]) : undefined;
|
|
90
|
+
return typeof picked === "number" ? picked : undefined;
|
|
91
|
+
};
|
|
92
|
+
|
|
86
93
|
const stringList = (value: unknown): string[] | undefined => (Array.isArray(value) ? value.map((entry) => String(entry)) : undefined);
|
|
87
94
|
|
|
88
95
|
const toRule = (raw: unknown, language: string, file: string): RuleDefinition => {
|
|
@@ -107,6 +114,7 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
|
|
|
107
114
|
extra_word_lists: stringList(raw["extra_word_lists"]) ?? [],
|
|
108
115
|
what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
|
|
109
116
|
where: typeof raw["where"] === "string" ? raw["where"] : undefined,
|
|
117
|
+
full_sentence: numberFor(raw["full_sentence"], language),
|
|
110
118
|
requires: stringList(raw["requires"]) ?? [],
|
|
111
119
|
uses: stringList(raw["uses"]) ?? [],
|
|
112
120
|
from: stringList(raw["from"]) ?? [],
|
package/src/run-semantic.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { ask, type JudgeOptions } from "./judge.ts";
|
|
|
3
3
|
import { FILTERS, type Candidate } from "./semantic.ts";
|
|
4
4
|
import { narrow, type Narrowing } from "./look-at.ts";
|
|
5
5
|
import { localized } from "./render/text.ts";
|
|
6
|
-
import type { Finding, Level, ProseDocument, RuleDefinition, Severity } from "./plugin.ts";
|
|
6
|
+
import type { AdapterNeeds, Finding, Level, ProseDocument, RuleDefinition, Severity } from "./plugin.ts";
|
|
7
7
|
import { lineStarts, placeOf } from "./position.ts";
|
|
8
|
+
import { wantsTags } from "./run.ts";
|
|
8
9
|
|
|
9
10
|
export type SemanticResult = {
|
|
10
11
|
readonly findings: readonly Finding[];
|
|
@@ -39,26 +40,33 @@ const severityFromLevel = (rule: RuleDefinition, level: Level): Severity => {
|
|
|
39
40
|
return SEVERITY_BY_VALUE[Math.min(3, Math.max(1, Math.round(value)))] ?? "warning";
|
|
40
41
|
};
|
|
41
42
|
|
|
43
|
+
/** この文書で動かす、意味を読む rule。 */
|
|
44
|
+
const semanticRules = (rules: readonly RuleDefinition[], settings: Readonly<Record<string, Level>>, genre: string): RuleDefinition[] =>
|
|
45
|
+
rules.filter((rule) => rule.layer === "L4" && rule.use_for.some((target) => genre.startsWith(target)) && settings[rule.id] !== "off");
|
|
46
|
+
|
|
47
|
+
/** 意味を読む rule の絞り込みが品詞を使うか。chaff test はこれで解析器を読み込む(neededBy は L4 を数えない)。 */
|
|
48
|
+
export const semanticNeeds = (rules: readonly RuleDefinition[], settings: Readonly<Record<string, Level>>, genre: string): AdapterNeeds => ({
|
|
49
|
+
pos: semanticRules(rules, settings, genre).some(wantsTags),
|
|
50
|
+
});
|
|
51
|
+
|
|
42
52
|
const builtInJobs = (doc: ProseDocument, rules: readonly RuleDefinition[], settings: Readonly<Record<string, Level>>, genre: string): Job[] =>
|
|
43
|
-
rules
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
];
|
|
61
|
-
});
|
|
53
|
+
semanticRules(rules, settings, genre).flatMap((rule) => {
|
|
54
|
+
const rubric = rule.what_to_check === undefined ? undefined : localized(rule.what_to_check, doc.language);
|
|
55
|
+
if (rubric === undefined || rubric.length === 0) return [];
|
|
56
|
+
// 絞り込みが無い rule は動かさない。黙って落とさず、下の skipped に出す。
|
|
57
|
+
const filter = filterFor(rule.how_to_find);
|
|
58
|
+
if (filter === undefined) return [];
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
rule: rule.id,
|
|
62
|
+
name: localized(rule.name, doc.language),
|
|
63
|
+
rubric,
|
|
64
|
+
howToFix: localized(rule.how_to_fix, doc.language),
|
|
65
|
+
severity: severityFromLevel(rule, settings[rule.id] ?? "normal"),
|
|
66
|
+
candidates: filter(doc),
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
});
|
|
62
70
|
|
|
63
71
|
const userJobs = (doc: ProseDocument, checks: readonly UserCheck[], genre: string): Job[] =>
|
|
64
72
|
checks
|
package/src/run.ts
CHANGED
|
@@ -112,6 +112,9 @@ const untagged = (rule: RuleDefinition, doc: ProseDocument): string | undefined
|
|
|
112
112
|
const forGenre = (rules: readonly RuleDefinition[], genre: string): RuleDefinition[] =>
|
|
113
113
|
rules.filter((rule) => rule.use_for.some((target) => genre.startsWith(target)));
|
|
114
114
|
|
|
115
|
+
/** rule が品詞か見出し語を要求するか、使えるなら使うか。 */
|
|
116
|
+
export const wantsTags = (rule: RuleDefinition): boolean => [...rule.requires, ...rule.uses].some((need) => need === "pos" || need === "lemma");
|
|
117
|
+
|
|
115
118
|
/**
|
|
116
119
|
* 解析器の初期化に払う代金を決める。動く rule が 1 本も要求しないなら読み込まない。
|
|
117
120
|
* capabilities は「払えばできる」の宣言なので、ここでは見ない。
|
|
@@ -120,7 +123,7 @@ export const neededBy = (rules: readonly RuleDefinition[], settings: Settings, e
|
|
|
120
123
|
pos: forGenre(rules, genre)
|
|
121
124
|
.filter((rule) => rule.layer !== "L4" && levelFor(rule, settings, experimental, presetLevels(genre)) !== "off")
|
|
122
125
|
.filter((rule) => rule.languages === undefined || rule.languages.includes(language))
|
|
123
|
-
.some(
|
|
126
|
+
.some(wantsTags),
|
|
124
127
|
});
|
|
125
128
|
|
|
126
129
|
const place = (starts: readonly number[], finding: Finding): Finding => {
|
|
@@ -201,6 +204,7 @@ export const runRules = (
|
|
|
201
204
|
limit: limitFor(rule, level, genre, limits),
|
|
202
205
|
lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
|
|
203
206
|
where: rule.where,
|
|
207
|
+
fullSentence: rule.full_sentence,
|
|
204
208
|
};
|
|
205
209
|
const found = detector(doc, options).map((finding) => place(starts, { ...finding, rule: rule.id, severity: rule.severity }));
|
|
206
210
|
return { findings: [...acc.findings, ...found], skipped: acc.skipped };
|
package/src/semantic.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { calendarUnitsOf } from "./calendar-number.ts";
|
|
2
|
+
import { closingAndEarlier, newNumbers } from "./closing-numbers.ts";
|
|
1
3
|
import type { ProseDocument, Section } from "./plugin.ts";
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -27,17 +29,23 @@ export const riskDisclosure: Filter = (doc) => {
|
|
|
27
29
|
return [{ text: body.slice(0, 4000), offset: doc.sentences[0]?.span.start ?? 0 }];
|
|
28
30
|
};
|
|
29
31
|
|
|
30
|
-
const EVIDENCE =
|
|
32
|
+
const EVIDENCE = /`|https?:\/\//u;
|
|
33
|
+
|
|
34
|
+
/** 結びに、前に書いていない数があるか。本文の数を繰り返すだけ(「以上のように、9月は…」)なら具体物ではない。 */
|
|
35
|
+
const hasNewNumber = (doc: ProseDocument, last: Section): boolean => {
|
|
36
|
+
const { closing, earlier } = closingAndEarlier(doc, last);
|
|
37
|
+
return newNumbers(closing, earlier, calendarUnitsOf(doc)).length > 0;
|
|
38
|
+
};
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
41
|
* 結びが本文の要約でしかないか。
|
|
34
|
-
*
|
|
42
|
+
* 最後の節だけを見て、そこに具体物(前に書いていない数、コード、リンク)が無いときにだけ問い合わせる。
|
|
35
43
|
*/
|
|
36
44
|
export const emptyConclusion: Filter = (doc) => {
|
|
37
45
|
const last = doc.sections.at(-1);
|
|
38
46
|
if (last === undefined || last.sentences.length === 0) return [];
|
|
39
47
|
const text = sectionText(doc, last);
|
|
40
|
-
if (EVIDENCE.test(text)) return [];
|
|
48
|
+
if (EVIDENCE.test(text) || hasNewNumber(doc, last)) return [];
|
|
41
49
|
return [{ text: text.slice(0, 2000), offset: last.sentences[0]?.span.start ?? last.span.start }];
|
|
42
50
|
};
|
|
43
51
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Span } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** 重なる範囲を 1 つにまとめ、始まりの順に並べる。joinTouching なら、接するだけの範囲(前の end が次の start)もまとめる。 */
|
|
4
|
+
export const mergeSpans = (spans: readonly Span[], joinTouching: boolean): Span[] =>
|
|
5
|
+
spans
|
|
6
|
+
.toSorted((left, right) => left.start - right.start)
|
|
7
|
+
.reduce<Span[]>((acc, span) => {
|
|
8
|
+
const last = acc.at(-1);
|
|
9
|
+
if (last !== undefined && (joinTouching ? span.start <= last.end : span.start < last.end))
|
|
10
|
+
acc[acc.length - 1] = { start: last.start, end: Math.max(last.end, span.end) };
|
|
11
|
+
else acc.push(span);
|
|
12
|
+
return acc;
|
|
13
|
+
}, []);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Span } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 戯曲・台本・議事録・書き起こしの、話し手の名前(`ALGERNON.`、行頭の `夫` と全角空白、`○事務局`)。
|
|
5
|
+
* 名前は誰が話すかの印であって本文ではない。文として数えると、名前だけの文が増え、名前が固有名詞と言い回しの繰り返しに入る。
|
|
6
|
+
*
|
|
7
|
+
* 形だけで決める。同じ形の行頭の名前が、MIN_SPEAKERS 人以上それぞれ MIN_TURNS 回以上繰り返され、どれも後に発言が続き、
|
|
8
|
+
* その形の行が文書の段落の多くを始める。1 つの名前の繰り返しは見出し(○ と全角空白の後の「注意事項」)、
|
|
9
|
+
* 繰り返されない名前は偶然、ときどき出るだけの `NOTE:` `WARNING:` は注記なので、話し手と読まない。
|
|
10
|
+
* そう読めた形では、1 度しか話さない人の名前も話し手の名前とする(議事録の委員)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 話し手と読むのに要る、繰り返す名前の数。 */
|
|
14
|
+
const MIN_SPEAKERS = 2;
|
|
15
|
+
/** 名前が繰り返されたと読む回数。 */
|
|
16
|
+
const MIN_TURNS = 3;
|
|
17
|
+
/** 対話と読むのに要る、その形の行の数。数回の注記(NOTE: と WARNING:)は対話ではない。 */
|
|
18
|
+
const MIN_DIALOGUE_TURNS = 10;
|
|
19
|
+
/** その形の行が始める段落の割合の下限。会議録は長い発言が何段落も続くので、戯曲より低い。 */
|
|
20
|
+
const MIN_TURN_SHARE = 0.1;
|
|
21
|
+
|
|
22
|
+
type Candidate = { readonly shape: string; readonly name: string; readonly span: Span };
|
|
23
|
+
|
|
24
|
+
/** 名前の後の発言が、同じ行にあるか(inline)、次の行にあるか(alone)。 */
|
|
25
|
+
type Shape = { readonly id: string; readonly pattern: RegExp; readonly alone: boolean };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 英語の名前は大文字で書いたものだけ(ALGERNON、LADY BRACKNELL、Mr. SMITH)。
|
|
29
|
+
* 大文字小文字まじりの「Title:」「Notes:」「Content-Type:」は、記録や表の項目の名前と形が同じで、見分けられない。
|
|
30
|
+
*/
|
|
31
|
+
const CAPS_WORD = String.raw`[A-Z][A-Z'’&-]*\.?`;
|
|
32
|
+
const CAPS_NAME = String.raw`(?:[A-Z][a-z]{1,3}\. )?${CAPS_WORD}(?: ${CAPS_WORD}){0,3}`;
|
|
33
|
+
/** 日本語の名前に入らない文字。空白、括弧、句読点、かぎ括弧、コロン。 */
|
|
34
|
+
const JA_NAME = String.raw`[^\s\u3000()()、。,.「」『』::]`;
|
|
35
|
+
|
|
36
|
+
/** 各 pattern の 1 番目の組が行頭の空白、2 番目が覆う範囲、`name` の組が名前。 */
|
|
37
|
+
const SHAPES: readonly Shape[] = [
|
|
38
|
+
{ id: "caps", pattern: new RegExp(String.raw`^(\s*)((?<name>${CAPS_NAME})[.:]?)\s*$`, "u"), alone: true },
|
|
39
|
+
{ id: "caps", pattern: new RegExp(String.raw`^(\s*)((?<name>${CAPS_NAME})[.:])\s+\S`, "u"), alone: false },
|
|
40
|
+
{ id: "mark", pattern: new RegExp(String.raw`^([ \t\u3000]*)([○◯〇](?<name>${JA_NAME}{1,20})(?:([^)\n]*))?)[ \t\u3000]*$`, "u"), alone: true },
|
|
41
|
+
{ id: "mark", pattern: new RegExp(String.raw`^([ \t\u3000]*)([○◯〇](?<name>${JA_NAME}{1,20})(?:([^)\n]*))?\u3000+)\S`, "u"), alone: false },
|
|
42
|
+
{ id: "spaced", pattern: new RegExp(String.raw`^([ \t]*)((?<name>${JA_NAME}{1,10})\u3000+)\S`, "u"), alone: false },
|
|
43
|
+
{ id: "quote", pattern: new RegExp(String.raw`^([ \t]*)((?<name>${JA_NAME}{1,10}))[ \t]*「`, "u"), alone: false },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 名前でないもの。番号(数字・漢数字・ローマ数字・「第」で始まる序数・1 字の仮名)は見出しや項目の印で、話し手ではない。
|
|
48
|
+
* 助詞で終わる語は地の文の主語(「私は「…」」)。英字 1 字は、名前より略語や項目の印が多い。
|
|
49
|
+
*/
|
|
50
|
+
const NUMERAL = /^[\d0-9一二三四五六七八九十百千〇IVXLCDM]+(?:の[\d0-9一二三四五六七八九十百千〇]+)*$/u;
|
|
51
|
+
const ORDINAL = /^第[\d0-9一二三四五六七八九十百千〇]/u;
|
|
52
|
+
const DIGIT = /[\d0-9]/u;
|
|
53
|
+
const SINGLE_KANA = /^[\p{Script=Hiragana}\p{Script=Katakana}]$/u;
|
|
54
|
+
const LETTER = /\p{L}/u;
|
|
55
|
+
const PARTICLE_END = /[はがもをにへとでやの]$/u;
|
|
56
|
+
|
|
57
|
+
const isName = (shape: string, name: string): boolean =>
|
|
58
|
+
LETTER.test(name) &&
|
|
59
|
+
!NUMERAL.test(name.replace(/[\s.'’&-]/gu, "")) &&
|
|
60
|
+
!ORDINAL.test(name) &&
|
|
61
|
+
!DIGIT.test(name) &&
|
|
62
|
+
!SINGLE_KANA.test(name) &&
|
|
63
|
+
!(shape === "quote" && PARTICLE_END.test(name)) &&
|
|
64
|
+
!(shape === "caps" && name.replace(/[^A-Z]/gu, "").length < 2);
|
|
65
|
+
|
|
66
|
+
type Line = { readonly start: number; readonly text: string };
|
|
67
|
+
|
|
68
|
+
const linesOf = (text: string): Line[] => {
|
|
69
|
+
const scan = { start: 0 };
|
|
70
|
+
return text.split("\n").map((raw) => {
|
|
71
|
+
const line = { start: scan.start, text: raw.replace(/\r$/u, "") };
|
|
72
|
+
scan.start += raw.length + 1;
|
|
73
|
+
return line;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const isBlank = (line: Line | undefined): boolean => line === undefined || line.text.trim() === "";
|
|
78
|
+
|
|
79
|
+
/** 名前だけの行は、次の行に発言があるときだけ話し手の名前(空行の後の大文字の行は見出し)。 */
|
|
80
|
+
const candidateOf = (lines: readonly Line[], index: number): Candidate | undefined => {
|
|
81
|
+
const line = lines[index];
|
|
82
|
+
if (line === undefined) return undefined;
|
|
83
|
+
return SHAPES.flatMap((shape) => {
|
|
84
|
+
const match = shape.pattern.exec(line.text);
|
|
85
|
+
const lead = match?.[1];
|
|
86
|
+
const label = match?.[2];
|
|
87
|
+
const name = match?.groups?.["name"]?.replace(/[.:]$/u, "");
|
|
88
|
+
if (lead === undefined || label === undefined || name === undefined || !isName(shape.id, name)) return [];
|
|
89
|
+
if (shape.alone && isBlank(lines[index + 1])) return [];
|
|
90
|
+
const start = line.start + lead.length;
|
|
91
|
+
return [{ shape: shape.id, name, span: { start, end: start + label.length } }];
|
|
92
|
+
})[0];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const countBy = <T>(items: readonly T[], keyOf: (item: T) => string): Map<string, number> =>
|
|
96
|
+
items.reduce((counts, item) => counts.set(keyOf(item), (counts.get(keyOf(item)) ?? 0) + 1), new Map<string, number>());
|
|
97
|
+
|
|
98
|
+
const speakerKey = (candidate: Candidate): string => `${candidate.shape}\n${candidate.name}`;
|
|
99
|
+
|
|
100
|
+
/** 形ごとに、MIN_TURNS 回以上出る名前が MIN_SPEAKERS 人以上いるか。 */
|
|
101
|
+
const speakerShapes = (candidates: readonly Candidate[]): Set<string> => {
|
|
102
|
+
const turns = countBy(candidates, speakerKey);
|
|
103
|
+
const speakers = [...new Map(candidates.map((candidate) => [speakerKey(candidate), candidate])).values()];
|
|
104
|
+
const repeated = speakers.filter((speaker) => (turns.get(speakerKey(speaker)) ?? 0) >= MIN_TURNS);
|
|
105
|
+
const perShape = countBy(repeated, (speaker) => speaker.shape);
|
|
106
|
+
return new Set([...perShape].filter(([, count]) => count >= MIN_SPEAKERS).map(([shape]) => shape));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** 空行の後から始まる、空でない行のまとまり(段落)の数。 */
|
|
110
|
+
const paragraphCount = (lines: readonly Line[]): number => lines.filter((line, index) => !isBlank(line) && (index === 0 || isBlank(lines[index - 1]))).length;
|
|
111
|
+
|
|
112
|
+
/** 話し手の形のうち、その形の行が MIN_DIALOGUE_TURNS 以上あり、段落の MIN_TURN_SHARE 以上を始めるもの。 */
|
|
113
|
+
const dialogueShapes = (candidates: readonly Candidate[], paragraphs: number): Set<string> => {
|
|
114
|
+
const turns = countBy(candidates, (candidate) => candidate.shape);
|
|
115
|
+
return new Set(
|
|
116
|
+
[...speakerShapes(candidates)].filter((shape) => {
|
|
117
|
+
const count = turns.get(shape) ?? 0;
|
|
118
|
+
return count >= MIN_DIALOGUE_TURNS && count >= paragraphs * MIN_TURN_SHARE;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** text の中の話し手の名前の範囲。発言は含まない。 */
|
|
124
|
+
export const speakerLabels = (text: string): Span[] => {
|
|
125
|
+
const lines = linesOf(text);
|
|
126
|
+
const candidates = lines.flatMap((_, index) => candidateOf(lines, index) ?? []);
|
|
127
|
+
const shapes = dialogueShapes(candidates, paragraphCount(lines));
|
|
128
|
+
return candidates.filter((candidate) => shapes.has(candidate.shape)).map((candidate) => candidate.span);
|
|
129
|
+
};
|