chaffjs 0.32.0 → 0.33.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.
Files changed (87) hide show
  1. package/README.md +7 -0
  2. package/dist/cli-text.js +2 -2
  3. package/dist/cross-run.d.ts.map +1 -1
  4. package/dist/cross-run.js +1 -0
  5. package/dist/cross-run.js.map +1 -1
  6. package/dist/detectors/agreement-slip.d.ts.map +1 -1
  7. package/dist/detectors/agreement-slip.js +4 -0
  8. package/dist/detectors/agreement-slip.js.map +1 -1
  9. package/dist/detectors/formula-amount.d.ts +3 -0
  10. package/dist/detectors/formula-amount.d.ts.map +1 -0
  11. package/dist/detectors/formula-amount.js +41 -0
  12. package/dist/detectors/formula-amount.js.map +1 -0
  13. package/dist/detectors/installment-total-mismatch.d.ts.map +1 -1
  14. package/dist/detectors/installment-total-mismatch.js +27 -8
  15. package/dist/detectors/installment-total-mismatch.js.map +1 -1
  16. package/dist/detectors/orthographic-variant.d.ts.map +1 -1
  17. package/dist/detectors/orthographic-variant.js +4 -3
  18. package/dist/detectors/orthographic-variant.js.map +1 -1
  19. package/dist/detectors/registry/formula-amount.d.ts +3 -0
  20. package/dist/detectors/registry/formula-amount.d.ts.map +1 -0
  21. package/dist/detectors/registry/formula-amount.js +3 -0
  22. package/dist/detectors/registry/formula-amount.js.map +1 -0
  23. package/dist/detectors/structure-tree.d.ts +1 -1
  24. package/dist/detectors/structure-tree.d.ts.map +1 -1
  25. package/dist/detectors/structure-tree.js +4 -2
  26. package/dist/detectors/structure-tree.js.map +1 -1
  27. package/dist/eval.js +3 -3
  28. package/dist/eval.js.map +1 -1
  29. package/dist/fix-plan/text.js +2 -2
  30. package/dist/fix-plan/text.js.map +1 -1
  31. package/dist/lexicon-genres.d.ts +7 -0
  32. package/dist/lexicon-genres.d.ts.map +1 -0
  33. package/dist/lexicon-genres.js +8 -0
  34. package/dist/lexicon-genres.js.map +1 -0
  35. package/dist/plugin.d.ts +6 -0
  36. package/dist/plugin.d.ts.map +1 -1
  37. package/dist/profile/parse.d.ts.map +1 -1
  38. package/dist/profile/parse.js +5 -0
  39. package/dist/profile/parse.js.map +1 -1
  40. package/dist/run.d.ts.map +1 -1
  41. package/dist/run.js +1 -0
  42. package/dist/run.js.map +1 -1
  43. package/dist/structure/date-range.d.ts +3 -2
  44. package/dist/structure/date-range.d.ts.map +1 -1
  45. package/dist/structure/date-range.js +4 -4
  46. package/dist/structure/date-range.js.map +1 -1
  47. package/dist/structure/formula-amount.d.ts +38 -0
  48. package/dist/structure/formula-amount.d.ts.map +1 -0
  49. package/dist/structure/formula-amount.js +187 -0
  50. package/dist/structure/formula-amount.js.map +1 -0
  51. package/dist/structure/installment-fee.d.ts +52 -0
  52. package/dist/structure/installment-fee.d.ts.map +1 -0
  53. package/dist/structure/installment-fee.js +110 -0
  54. package/dist/structure/installment-fee.js.map +1 -0
  55. package/dist/structure/installment-total.d.ts +7 -0
  56. package/dist/structure/installment-total.d.ts.map +1 -1
  57. package/dist/structure/installment-total.js +6 -6
  58. package/dist/structure/installment-total.js.map +1 -1
  59. package/dist/structure/start-end-range.d.ts +30 -0
  60. package/dist/structure/start-end-range.d.ts.map +1 -0
  61. package/dist/structure/start-end-range.js +79 -0
  62. package/dist/structure/start-end-range.js.map +1 -0
  63. package/package.json +4 -4
  64. package/profiles/property-sale.yaml +21 -0
  65. package/rules/date-range-reversed.yaml +4 -4
  66. package/rules/formula-amount-mismatch.yaml +67 -0
  67. package/rules/installment-total-mismatch.yaml +9 -4
  68. package/rules/orthographic-variant.yaml +3 -3
  69. package/src/cli-text.ts +2 -2
  70. package/src/cross-run.ts +1 -0
  71. package/src/detectors/agreement-slip.ts +3 -0
  72. package/src/detectors/formula-amount.ts +59 -0
  73. package/src/detectors/installment-total-mismatch.ts +34 -8
  74. package/src/detectors/orthographic-variant.ts +5 -3
  75. package/src/detectors/registry/formula-amount.ts +4 -0
  76. package/src/detectors/structure-tree.ts +4 -2
  77. package/src/eval.ts +3 -3
  78. package/src/fix-plan/text.ts +2 -2
  79. package/src/lexicon-genres.ts +11 -0
  80. package/src/plugin.ts +6 -0
  81. package/src/profile/parse.ts +8 -0
  82. package/src/run.ts +1 -0
  83. package/src/structure/date-range.ts +4 -3
  84. package/src/structure/formula-amount.ts +236 -0
  85. package/src/structure/installment-fee.ts +201 -0
  86. package/src/structure/installment-total.ts +6 -6
  87. package/src/structure/start-end-range.ts +120 -0
@@ -109,7 +109,7 @@ export const FIX_PLAN_TEXT: Texts<FixPlanText> = {
109
109
  spots: "見つけた箇所",
110
110
  at: (line) => `${String(line)} 行目`,
111
111
  notRunHeading: "動かなかったルール",
112
- notExperimental: "試験中のルールは動かしていません。AIっぽさのルールの多くは試験中です。--experimental を付けると見ます。",
112
+ notExperimental: "試験中のルールは動かしていません。--experimental を付けると見ます。",
113
113
  checksHeading: "直したあとの確かめ",
114
114
  saveAs: (path) => `書き直したものを ${path} に保存して、次を実行します。`,
115
115
  checksNote:
@@ -174,7 +174,7 @@ export const FIX_PLAN_TEXT: Texts<FixPlanText> = {
174
174
  spots: "Spots",
175
175
  at: (line) => `line ${String(line)}`,
176
176
  notRunHeading: "Rules that did not run",
177
- notExperimental: "The experimental rules did not run, and most rules for generated text are experimental. Add --experimental to run them.",
177
+ notExperimental: "The experimental rules did not run. Add --experimental to run them.",
178
178
  checksHeading: "Check after rewriting",
179
179
  saveAs: (path) => `Save the rewrite as ${path} and run:`,
180
180
  checksNote:
@@ -0,0 +1,11 @@
1
+ import type { Lexicon } from "./plugin.ts";
2
+
3
+ /** A genre is in a target when it is the target or one of its genres (legal/contract is in legal). */
4
+ const isIn = (genre: string, target: string): boolean => genre === target || genre.startsWith(`${target}/`);
5
+
6
+ /**
7
+ * The entries of a word list that hold for the document's genre: an entry without genres holds everywhere, one with genres only
8
+ * in those genres or groups. With no genre known, only the entries for every genre.
9
+ */
10
+ export const entriesForGenre = (lexicon: Lexicon, genre: string | undefined): Lexicon =>
11
+ lexicon.filter((entry) => entry.genres === undefined || (genre !== undefined && entry.genres.some((target) => isIn(genre, target))));
package/src/plugin.ts CHANGED
@@ -68,6 +68,8 @@ export type LexiconEntry = {
68
68
  readonly group?: string | undefined;
69
69
  /** 元号の元年の西暦の年(令和は 2019)。元号で書いた年を西暦の年にする rule が使う。 */
70
70
  readonly first_year?: number | undefined;
71
+ /** The genres or groups the entry holds in (legal, blog/tech). Without them, every genre. A detector reads them with entriesForGenre. */
72
+ readonly genres?: readonly string[] | undefined;
71
73
  /** pattern を adapter が語に分けたもの。品詞が読めるときだけ core が入れる。語彙表を書く側は書かない。 */
72
74
  readonly tokens?: readonly Token[] | undefined;
73
75
  };
@@ -281,6 +283,8 @@ export type DocumentProfile = {
281
283
  readonly quoteMentionsTerm?: boolean | undefined;
282
284
  /** 文書に速さが無いときの、徒歩の分数を出す速さ(m/分)と、端数を切り上げるか。不動産の広告は 80m で1分、切り上げ。 */
283
285
  readonly walkRate?: { readonly metresPerMinute: number; readonly roundUp: boolean } | undefined;
286
+ /** ラベルが「消費税込み」とだけ書き、ラベルにも文書にも税率が無いときの税率(%)。 */
287
+ readonly taxRate?: number | undefined;
284
288
  };
285
289
 
286
290
  /**
@@ -427,6 +431,8 @@ export type DetectorOptions = {
427
431
  readonly settings?: Readonly<Record<string, OptionValue>> | undefined;
428
432
  /** A custom rule's spec (RuleDefinition.custom). */
429
433
  readonly custom?: CustomSpec | undefined;
434
+ /** The document's genre (legal/contract), for word-list entries that hold in some genres only. */
435
+ readonly genre?: string | undefined;
430
436
  };
431
437
 
432
438
  /** The value of one rule option: a choice, a count, or a list of words. */
@@ -61,6 +61,12 @@ const walkRateOf = (value: unknown): DocumentProfile["walkRate"] => {
61
61
  return { metresPerMinute, roundUp: value["round_up"] === true };
62
62
  };
63
63
 
64
+ const WHOLE_PERCENT = 100;
65
+
66
+ /** 0 より大きく 100 未満の数でなければ読まない。 */
67
+ const taxRateOf = (value: unknown): number | undefined =>
68
+ typeof value === "number" && Number.isFinite(value) && value > 0 && value < WHOLE_PERCENT ? value : undefined;
69
+
64
70
  /** 単位が一つも無ければ読まない。何を指すのか決められない。 */
65
71
  const relativeOf = (value: unknown): RelativeVocabulary | undefined => {
66
72
  if (!isRecord(value)) return undefined;
@@ -97,6 +103,7 @@ const languagesOf = (id: string, raw: Record<string, unknown>): Record<string, D
97
103
  const relative = relativeOf(section["relative"]);
98
104
  const unnumbered = unnumberedOf(section["unnumbered"]);
99
105
  const walkRate = walkRateOf(section["walk_rate"]);
106
+ const taxRate = taxRateOf(section["tax_rate"]);
100
107
  return [
101
108
  language,
102
109
  {
@@ -109,6 +116,7 @@ const languagesOf = (id: string, raw: Record<string, unknown>): Record<string, D
109
116
  unnumbered,
110
117
  ...(section["quote_mentions_term"] === true ? { quoteMentionsTerm: true } : {}),
111
118
  ...(walkRate === undefined ? {} : { walkRate }),
119
+ ...(taxRate === undefined ? {} : { taxRate }),
112
120
  },
113
121
  ];
114
122
  }),
package/src/run.ts CHANGED
@@ -307,6 +307,7 @@ export const runRulesWith = (doc: ProseDocument, rules: readonly RuleDefinition[
307
307
  embeddedLimits: embeddedLimitsFor(rule, level, genre, embedded),
308
308
  ...(rule.options === undefined ? {} : { settings: optionValues(settleOptions(rule.id, rule.options, optionLayers)) }),
309
309
  ...(rule.custom === undefined ? {} : { custom: rule.custom }),
310
+ genre,
310
311
  };
311
312
  const ran = runDetector(detector, doc, options);
312
313
  if ("notRun" in ran) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: ran.notRun }] };
@@ -55,7 +55,7 @@ export const rangeFrameOf = (pattern: string): RangeFrame | undefined => {
55
55
  /** 日付に添えた曜日((水))と時刻(10:00)は、期間のつなぎ目ではないので読み飛ばす。 */
56
56
  const ASIDES = /[((][^(())\n]{1,6}[))]|\d{1,2}[::]\d{2}/gu;
57
57
 
58
- const bare = (text: string): string => text.replace(ASIDES, "").trim().toLowerCase();
58
+ export const bare = (text: string): string => text.replace(ASIDES, "").trim().toLowerCase();
59
59
 
60
60
  const isOneOf = (text: string, words: readonly string[]): boolean => words.some((word) => word.toLowerCase() === text);
61
61
 
@@ -159,17 +159,18 @@ const writtenPeriod = (source: string, start: DatedSpan, end: DatedSpan): string
159
159
 
160
160
  /**
161
161
  * 隣り合う二つの日付のうち、期間をなし、終わりが始まりより前のもの。期間の書き出しを指し、書いたままの期間を見せる。
162
- * isLabelled は、その位置の日付が期間の語で始まる行(Dates: …)の、語より後ろにあるか。
162
+ * isLabelled は、その位置の日付が期間の語で始まる行(Dates: …)の、語より後ろにあるか。alsoRange は、ほかの書き方で期間と読める組か。
163
163
  */
164
164
  export const reversedRanges = (
165
165
  source: string,
166
166
  dates: readonly DatedSpan[],
167
167
  words: RangeWords,
168
168
  isLabelled: (offset: number) => boolean = () => false,
169
+ alsoRange: (start: DatedSpan, end: DatedSpan) => boolean = () => false,
169
170
  ): StructureIssue[] =>
170
171
  dates.slice(1).flatMap((end, index) => {
171
172
  const start = dates[index];
172
173
  const reading = start === undefined ? undefined : reversedReading(start, end);
173
- if (start === undefined || reading === undefined || !isRange(source, start, end, words, isLabelled)) return [];
174
+ if (start === undefined || reading === undefined || !(isRange(source, start, end, words, isLabelled) || alsoRange(start, end))) return [];
174
175
  return [{ offset: start.offset, values: { start: reading.start, end: reading.end, period: writtenPeriod(source, start, end) } }];
175
176
  });
@@ -0,0 +1,236 @@
1
+ import type { StructureIssue } from "./issues.ts";
2
+ import { linesOf, type Line } from "./lines.ts";
3
+ import { alternation, intervalOf, type Interval, type StatedAmount } from "./stated-multiple.ts";
4
+ import { statedTaxRates, type TaxPriceWord } from "./tax-price.ts";
5
+ import { roundedMatches } from "./tax.ts";
6
+ import { escapeRegExp } from "../orthography.ts";
7
+
8
+ /**
9
+ * formula-amount-mismatch: a row whose label writes the formula of its amount (仲介手数料(売買代金の3%+60,000円、消費税込み)
10
+ * | 1,650,000円, "Commission (5% of the sale price) | $16,200.00") is worked out from the amount the document labels with that
11
+ * name (売買代金 | 48,000,000円) and compared with the row's amount. A label that says the tax is included adds the rate
12
+ * the label states, else the one rate the document states beside a tax word, else the profile's rate. The words come from the
13
+ * lexicons. Pure.
14
+ */
15
+
16
+ export type FormulaWords = {
17
+ /** Words between a name and its rate (売買代金の3%). */
18
+ readonly nameRate: readonly string[];
19
+ /** Words between a rate and its name (3% of the sale price). */
20
+ readonly rateName: readonly string[];
21
+ /** Words before a fixed amount added to the rate's part (+60,000円, plus $500). */
22
+ readonly plus: readonly string[];
23
+ /** The marks of a percent (%, %, percent). */
24
+ readonly percent: readonly string[];
25
+ /** The words of a price with tax and of a tax rate (税込み, 消費税), as tax-mismatch reads them. */
26
+ readonly tax: readonly TaxPriceWord[];
27
+ /** The names of a tax (消費税, VAT) and the words that say it is included (込み, included): 消費税8%込み, VAT included. */
28
+ readonly taxNames: readonly string[];
29
+ readonly includedWords: readonly string[];
30
+ /** Words that say a tax is left out although a word of inclusion stands in them (not included, 含まない). */
31
+ readonly notIncluded: readonly string[];
32
+ };
33
+
34
+ export type FormulaText = {
35
+ readonly text: string;
36
+ readonly amounts: readonly StatedAmount[];
37
+ /** The tax rate a label's tax-included word means when neither the label nor the document states one (the profile's). */
38
+ readonly defaultTaxRate: number | undefined;
39
+ };
40
+
41
+ /** The rate and the name it is of, with where they stand in the label. */
42
+ type Formula = { readonly start: number; readonly end: number; readonly rate: number; readonly name: string };
43
+ type Named = Omit<Formula, "rate">;
44
+
45
+ /** A line ending in one amount after a separator, and the label before that separator. */
46
+ type Row = { readonly line: Line; readonly label: string; readonly amount: StatedAmount };
47
+
48
+ /** What the row's amount is worked out from. */
49
+ type Terms = { readonly formula: Formula; readonly base: StatedAmount; readonly fixed: StatedAmount | undefined; readonly tax: number };
50
+
51
+ const CENTS = 100;
52
+ const HUNDRED = 100;
53
+ const NAME_MAX = 40;
54
+ const AFTER_AMOUNT = /^[\s||]*$/u;
55
+ const SEPARATOR = /[||::]\s*$/u;
56
+ const NAME_EDGE = "()()、,,;;||::「」『』";
57
+ const LAST_EDGE = new RegExp(`[${escapeRegExp(NAME_EDGE)}][^${escapeRegExp(NAME_EDGE)}]*$`, "u");
58
+ const LIST_MARK = /^(?:[-*+・]|\d+[.)])\s+/u;
59
+ const DIGITS = /[0-90-9]/u;
60
+ const NUMBER = /\d[\d,]*(?:\.\d+)?/u;
61
+ /** Four digits or more with no comma: the row groups none, so the expected amount is not grouped either. */
62
+ const UNGROUPED = /\d{4}/u;
63
+
64
+ const numberOf = (written: string): number => Number(written.normalize("NFKC"));
65
+
66
+ const percentPattern = (words: FormulaWords): RegExp | undefined => {
67
+ const marks = alternation(words.percent);
68
+ return marks === undefined ? undefined : new RegExp(`(?<![0-90-9.,.,])([0-90-9]+(?:[..][0-90-9]+)?)\\s?(?:${marks})`, "giu");
69
+ };
70
+
71
+ const amountsWithin = (amounts: readonly StatedAmount[], start: number, end: number): StatedAmount[] =>
72
+ amounts.filter((amount) => amount.offset >= start && amount.end <= end);
73
+
74
+ /** The line's last amount, with only a separator before it and nothing after it, and the text before that separator. */
75
+ const rowOf = (text: string, line: Line, amounts: readonly StatedAmount[]): Row | undefined => {
76
+ const end = line.start + line.text.length;
77
+ const amount = amountsWithin(amounts, line.start, end).at(-1);
78
+ if (amount === undefined || amount.scale !== undefined || !AFTER_AMOUNT.test(text.slice(amount.end, end))) return undefined;
79
+ const before = text.slice(line.start, amount.offset);
80
+ const separator = SEPARATOR.exec(before);
81
+ return separator === null ? undefined : { line, label: before.slice(0, separator.index), amount };
82
+ };
83
+
84
+ const isName = (name: string): boolean => name !== "" && name.length <= NAME_MAX && !DIGITS.test(name);
85
+
86
+ /** 売買代金の3%: the name runs back from the word to the nearest bracket, comma or cell edge. */
87
+ const nameBefore = (label: string, rateStart: number, words: FormulaWords): Named | undefined => {
88
+ const head = label.slice(0, rateStart).trimEnd();
89
+ const word = words.nameRate.find((each) => head.toLowerCase().endsWith(each.toLowerCase()));
90
+ if (word === undefined) return undefined;
91
+ const upTo = head.slice(0, head.length - word.length);
92
+ const edge = LAST_EDGE.exec(upTo);
93
+ const from = edge === null ? 0 : edge.index + 1;
94
+ const raw = upTo.slice(from).replace(LIST_MARK, "");
95
+ const name = raw.trim();
96
+ return isName(name) ? { start: upTo.length - raw.trimStart().length, end: rateStart, name } : undefined;
97
+ };
98
+
99
+ /** 3% of the sale price: the name runs on from the word to a bracket, a comma, a plus word or a tax phrase (incl. tax). */
100
+ const nameAfter = (label: string, rateEnd: number, words: FormulaWords): Named | undefined => {
101
+ const joiner = alternation(words.rateName);
102
+ const word = joiner === undefined ? null : new RegExp(`^\\s*(?:${joiner})\\s+`, "iu").exec(label.slice(rateEnd));
103
+ if (word === null) return undefined;
104
+ const from = rateEnd + word[0].length;
105
+ const taxPhrases = words.tax.filter((each) => each.role !== "rate").map((each) => each.pattern);
106
+ const stops = [`[${escapeRegExp(NAME_EDGE)}]`, alternation([...words.plus, ...taxPhrases]) ?? "(?!)"];
107
+ const stop = new RegExp(stops.join("|"), "iu").exec(label.slice(from));
108
+ const name = label.slice(from, stop === null ? label.length : from + stop.index).trim();
109
+ return isName(name) ? { start: from, end: from + name.length, name } : undefined;
110
+ };
111
+
112
+ /** The one rate in the label that names what it is of; two such rates are not read. */
113
+ const formulaIn = (label: string, words: FormulaWords): Formula | undefined => {
114
+ const pattern = percentPattern(words);
115
+ if (pattern === undefined) return undefined;
116
+ const found = [...label.matchAll(pattern)].flatMap((match) => {
117
+ const rateEnd = match.index + match[0].length;
118
+ const named = nameBefore(label, match.index, words) ?? nameAfter(label, rateEnd, words);
119
+ if (named === undefined) return [];
120
+ return [{ start: Math.min(named.start, match.index), end: Math.max(named.end, rateEnd), rate: numberOf(match[1] ?? ""), name: named.name }];
121
+ });
122
+ const others = [...label.matchAll(pattern)].length - found.length;
123
+ return found.length === 1 && others <= statedTaxRates([label], words.tax).length ? found[0] : undefined;
124
+ };
125
+
126
+ /**
127
+ * The amount right after a plus word after the formula (+60,000円), or none. Undefined when the label holds any other
128
+ * amount, or a plus word with no amount after it: the formula is then not all written in the label.
129
+ */
130
+ const fixedOf = (text: string, row: Row, formula: Formula, amounts: readonly StatedAmount[], words: FormulaWords): StatedAmount[] | undefined => {
131
+ const inLabel = amountsWithin(amounts, row.line.start, row.line.start + row.label.length);
132
+ const plus = alternation(words.plus);
133
+ const after = row.line.start + formula.end;
134
+ const gap = plus === undefined ? null : new RegExp(`^\\s*(?:${plus})\\s*`, "iu").exec(text.slice(after, row.line.start + row.label.length));
135
+ const fixed = gap === null ? undefined : inLabel.find((amount) => amount.offset === after + gap[0].length);
136
+ if (gap !== null && fixed === undefined) return undefined;
137
+ const parts = fixed === undefined ? [] : [fixed];
138
+ return inLabel.length === parts.length ? parts : undefined;
139
+ };
140
+
141
+ /**
142
+ * A word of a price with tax (税込み, incl. tax), or a tax's name with a word of inclusion (消費税8%込み, VAT included); not
143
+ * when the label also says the tax is left out (税別, excl. tax, tax not included).
144
+ */
145
+ const isTaxIncluded = (label: string, words: FormulaWords): boolean => {
146
+ const lower = label.normalize("NFKC").toLowerCase();
147
+ const holds = (word: string): boolean => lower.includes(word.normalize("NFKC").toLowerCase());
148
+ const roleHeld = (role: TaxPriceWord["role"]): boolean => words.tax.some((word) => word.role === role && holds(word.pattern));
149
+ if (roleHeld("excluded") || words.notIncluded.some(holds)) return false;
150
+ return roleHeld("included") || (words.taxNames.some(holds) && words.includedWords.some(holds));
151
+ };
152
+
153
+ /** 0 without a tax-included word; else the label's one rate, the document's one rate, or the profile's. Undefined when unknown. */
154
+ const taxRateOf = (row: Row, others: readonly string[], text: FormulaText, words: FormulaWords): number | undefined => {
155
+ if (!isTaxIncluded(row.label, words)) return 0;
156
+ const inLabel = statedTaxRates([row.label], words.tax);
157
+ if (inLabel.length > 0) return inLabel.length === 1 ? inLabel[0] : undefined;
158
+ const stated = statedTaxRates(others, words.tax);
159
+ if (stated.length > 0) return stated.length === 1 ? stated[0] : undefined;
160
+ return text.defaultTaxRate;
161
+ };
162
+
163
+ /** The amount the document labels with the name (売買代金 | 48,000,000円, Sale price: $324,000), when every such label agrees. */
164
+ const baseOf = (text: string, lines: readonly Line[], row: Row, name: string, amounts: readonly StatedAmount[]): StatedAmount | undefined => {
165
+ const label = new RegExp(`^[ \\t]*(?:[-*+][ \\t]+)?(?:[||][ \\t]*)?(?:\\*\\*)?${escapeRegExp(name)}(?:\\*\\*)?[ \\t]*[||::][ \\t]*`, "iu");
166
+ const found = lines.flatMap((line) => {
167
+ const match = line === row.line ? null : label.exec(line.text);
168
+ const amount = match === null ? undefined : amounts.find((each) => each.offset === line.start + match[0].length);
169
+ return amount === undefined || amount.currency !== row.amount.currency ? [] : [amount];
170
+ });
171
+ const [first] = found;
172
+ return found.every((amount) => amount.value === first?.value && amount.scale === first.scale) ? first : undefined;
173
+ };
174
+
175
+ /** Before tax: rate × base + fixed. The base is the interval its written digits allow; a formula's fixed part is exact (+6万円 is 60,000). */
176
+ const beforeTax = (text: string, terms: Terms): Interval => {
177
+ const base = intervalOf(terms.base, text.slice(terms.base.offset, terms.base.end));
178
+ const fixed = terms.fixed?.value ?? 0;
179
+ return { low: (base.low * terms.formula.rate) / HUNDRED + fixed, high: (base.high * terms.formula.rate) / HUNDRED + fixed };
180
+ };
181
+
182
+ const ROUNDINGS: readonly ((value: number) => number)[] = [(value) => value, Math.floor, Math.round, Math.ceil];
183
+
184
+ /** Whether the written amount is the formula's, rounded down, to nearest or up, before tax or after it, to the unit or the cent. */
185
+ export const formulaMatches = (written: number, before: Interval, tax: number): boolean => {
186
+ const withTax = (value: number): number => (value * (HUNDRED + tax)) / HUNDRED;
187
+ // A base written with a word of scale (4,800万円) is a range; any amount in it, rounded to the unit, is right.
188
+ if (before.low < before.high && written >= Math.floor(withTax(before.low)) && written <= Math.ceil(withTax(before.high))) return true;
189
+ const candidates = [before.low, before.high].flatMap((value) => ROUNDINGS.map((round) => withTax(round(value))));
190
+ return candidates.some((candidate) => roundedMatches(Math.round(written * CENTS), candidate * CENTS));
191
+ };
192
+
193
+ /** The expected amount as the row writes it: its unit, its grouping and its decimals. */
194
+ const shownLike = (written: string, value: number): string => {
195
+ const decimals = NUMBER.exec(written.normalize("NFKC"))?.[0].split(".")[1]?.length ?? 0;
196
+ const grouped = written.includes(",") || !UNGROUPED.test(written.normalize("NFKC"));
197
+ const number = value.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping: grouped });
198
+ return written.normalize("NFKC").replace(NUMBER, number);
199
+ };
200
+
201
+ const issueOf = (text: string, row: Row, terms: Terms): StructureIssue[] => {
202
+ const before = beforeTax(text, terms);
203
+ if (formulaMatches(row.amount.value, before, terms.tax)) return [];
204
+ const expected = (((before.low + before.high) / 2) * (HUNDRED + terms.tax)) / HUNDRED;
205
+ const values = {
206
+ written: text.slice(row.amount.offset, row.amount.end),
207
+ expected: shownLike(text.slice(row.amount.offset, row.amount.end), Math.round(expected)),
208
+ formula: text.slice(row.line.start + terms.formula.start, terms.fixed?.end ?? row.line.start + terms.formula.end),
209
+ name: terms.formula.name,
210
+ base: text.slice(terms.base.offset, terms.base.end),
211
+ };
212
+ return [{ offset: row.amount.offset, values }];
213
+ };
214
+
215
+ const termsOf = (input: FormulaText, lines: readonly Line[], row: Row, words: FormulaWords): Terms | undefined => {
216
+ const formula = formulaIn(row.label, words);
217
+ if (formula === undefined) return undefined;
218
+ const fixedList = fixedOf(input.text, row, formula, input.amounts, words);
219
+ const [fixed] = fixedList ?? [];
220
+ if (fixedList === undefined || (fixed !== undefined && fixed.currency !== row.amount.currency)) return undefined;
221
+ const base = baseOf(input.text, lines, row, formula.name, input.amounts);
222
+ const others = lines.filter((line) => line !== row.line).map((line) => line.text);
223
+ const tax = taxRateOf(row, others, input, words);
224
+ return base === undefined || tax === undefined ? undefined : { formula, base, fixed, tax };
225
+ };
226
+
227
+ /** Every row whose label writes its formula and whose amount is not what the formula gives on the named amount. */
228
+ export const formulaAmountMismatches = (input: FormulaText, words: FormulaWords): StructureIssue[] => {
229
+ if (words.percent.length === 0 || words.nameRate.length + words.rateName.length === 0) return [];
230
+ const lines = linesOf(input.text);
231
+ return lines.flatMap((line) => {
232
+ const row = rowOf(input.text, line, input.amounts);
233
+ const terms = row === undefined ? undefined : termsOf(input, lines, row, words);
234
+ return row === undefined || terms === undefined ? [] : issueOf(input.text, row, terms);
235
+ });
236
+ };
@@ -0,0 +1,201 @@
1
+ import { escapeRegExp } from "../orthography.ts";
2
+ import type { StructureIssue } from "./issues.ts";
3
+ import {
4
+ alternation,
5
+ describe,
6
+ hasWord,
7
+ intervalOf,
8
+ isPlainGap,
9
+ isRough,
10
+ isTotalOf,
11
+ lineBounds,
12
+ occurrences,
13
+ sentenceAround,
14
+ sumOf,
15
+ writtenLike,
16
+ type InstallmentAmount,
17
+ type InstallmentWords,
18
+ type Part,
19
+ } from "./installment-total.ts";
20
+
21
+ /**
22
+ * installment-total-mismatch, the fee form: numbered instalments listed one per row or item (第1回:32,000円, | 1st
23
+ * payment | £150 |, Final payment: $60) that do not add up to the fee stated in the same section (旅行代金は、1人あたり
24
+ * 62,000円; "The trip costs £300 per student"). Compared only when the section states one fee, every instalment of the
25
+ * series is read (numbered 1, 2, … with no gap or repeat, each with one plain amount), the section names a payment
26
+ * (積立金, instalments), and every amount is in the fee's currency. A deposit counts only as an item of the series; one
27
+ * named anywhere else stops the comparison. An instalment as large as the fee says the fee is per payment (a monthly
28
+ * fee), not split, and is not compared. Words come from the lexicons. Pure.
29
+ */
30
+
31
+ type Span = { readonly start: number; readonly end: number };
32
+
33
+ export type InstallmentSeriesWords = {
34
+ /** Item labels with {n} for the instalment's number (第{n}回, {n} payment, Instalment {n}). */
35
+ readonly numbered: readonly string[];
36
+ /** Item labels of the last instalment, numbered after the others (最終回, Final payment). */
37
+ readonly finals: readonly string[];
38
+ /** Numbers written as words that {n} may be (first, 一) and their values. */
39
+ readonly ordinals: readonly { readonly word: string; readonly value: number }[];
40
+ /** Letters after a number that make it an ordinal (1st). */
41
+ readonly ordinalSuffixes: readonly string[];
42
+ /** Words that say the section is about paying (積立金, instalment); one must stand in the section. */
43
+ readonly payments: readonly string[];
44
+ /** A deposit (申込金, deposit): an item of the series, or a reason not to compare. */
45
+ readonly deposits: readonly string[];
46
+ /** Words that state the fee (旅行代金, costs). */
47
+ readonly fees: readonly string[];
48
+ /** Words that may stand between a fee word and its amount (1人あたり, per student). */
49
+ readonly feeLinks: readonly string[];
50
+ };
51
+
52
+ /** One read item of a series: its number (undefined for a deposit, Infinity for a final one) and its amount. */
53
+ type Item = { readonly line: Span; readonly number: number | undefined; readonly amount: InstallmentAmount | undefined };
54
+
55
+ /** What may stand before an item label on its line: a table bar, a list mark, spaces. */
56
+ const ITEM_LEAD = /^[\s||\-*+・•]*$/u;
57
+ const DIGITS = "[0-90-9]+";
58
+ const FINAL = Number.POSITIVE_INFINITY;
59
+
60
+ const numberPattern = (series: InstallmentSeriesWords): string => {
61
+ const suffix = alternation(series.ordinalSuffixes);
62
+ const words = alternation(series.ordinals.map((entry) => entry.word));
63
+ const digits = suffix === undefined ? DIGITS : `${DIGITS}(?:${suffix})?`;
64
+ return words === undefined ? digits : `${digits}|${words}`;
65
+ };
66
+
67
+ const numberOf = (written: string, series: InstallmentSeriesWords): number | undefined => {
68
+ const digits = /^[0-90-9]+/u.exec(written)?.[0];
69
+ if (digits !== undefined) return Number(digits.normalize("NFKC"));
70
+ return series.ordinals.find((entry) => entry.word.toLowerCase() === written.toLowerCase())?.value;
71
+ };
72
+
73
+ const labelRegExp = (label: string, number: string): RegExp => {
74
+ const [before = "", after = ""] = label.split("{n}").map(escapeRegExp);
75
+ const edgeStart = /^[A-Za-z]/u.test(label) ? "(?<![A-Za-z])" : "";
76
+ const edgeEnd = /[A-Za-z]$/u.test(label) ? "(?![A-Za-z])" : "";
77
+ return new RegExp(`${edgeStart}${before}(${number})${after}${edgeEnd}`, "giu");
78
+ };
79
+
80
+ type Label = Span & { readonly number: number | undefined };
81
+
82
+ const numberedLabels = (text: string, series: InstallmentSeriesWords): Label[] =>
83
+ series.numbered.flatMap((label) =>
84
+ [...text.matchAll(labelRegExp(label, numberPattern(series)))].map((match) => ({
85
+ start: match.index,
86
+ end: match.index + match[0].length,
87
+ number: numberOf(match[1] ?? "", series),
88
+ })),
89
+ );
90
+
91
+ const wordLabels = (text: string, words: readonly string[], number: number | undefined): Label[] =>
92
+ occurrences(text, words).map((span) => ({ ...span, number }));
93
+
94
+ const startsItem = (text: string, label: Span): boolean => ITEM_LEAD.test(text.slice(lineBounds(text, label.start).start, label.start));
95
+
96
+ /** The longest label at each place wins (Final payment over a bare payment), and only labels that open a row or an item. */
97
+ const itemLabels = (text: string, series: InstallmentSeriesWords): Label[] => {
98
+ const all = [...numberedLabels(text, series), ...wordLabels(text, series.finals, FINAL), ...wordLabels(text, series.deposits, undefined)];
99
+ const longest = all.filter(
100
+ (label) =>
101
+ !all.some((other) => other !== label && other.start <= label.start && other.end >= label.end && other.end - other.start > label.end - label.start),
102
+ );
103
+ return longest.filter((label) => startsItem(text, label)).toSorted((left, right) => left.start - right.start);
104
+ };
105
+
106
+ /** A label's amount: the one amount of its line, after a gap of separators, links and payment words; else unread. */
107
+ const itemOf = (text: string, label: Label, amounts: readonly InstallmentAmount[], words: InstallmentWords, series: InstallmentSeriesWords): Item => {
108
+ const line = lineBounds(text, label.start);
109
+ const onLine = amounts.filter((amount) => amount.offset >= label.end && amount.end <= line.end);
110
+ const [amount] = onLine;
111
+ const read =
112
+ onLine.length === 1 &&
113
+ amount !== undefined &&
114
+ isPlainGap(text.slice(label.end, amount.offset), [...words.links, ...series.payments]) &&
115
+ !isRough(text, { start: amount.offset, end: amount.end }, words);
116
+ return { line, number: label.number, amount: read ? amount : undefined };
117
+ };
118
+
119
+ /** Whether the numbered items run 1, 2, …, k (a final one only last), each read; deposits may stand anywhere. */
120
+ export const isWholeSeries = (items: readonly Item[]): boolean => {
121
+ const numbers = items.flatMap((item) => (item.number === undefined ? [] : [item.number]));
122
+ const finalAt = numbers.indexOf(FINAL);
123
+ const counted = numbers.filter((number) => number !== FINAL);
124
+ const inOrder = counted.every((number, index) => number === index + 1);
125
+ const finalLast = finalAt === -1 || finalAt === numbers.length - 1;
126
+ return items.length >= 2 && counted.length >= 1 && inOrder && finalLast && items.every((item) => item.amount !== undefined);
127
+ };
128
+
129
+ /** The sections of the text: from one heading to the next; the whole text when there are none. */
130
+ export const sectionsOf = (text: string, headingStarts: readonly number[]): Span[] => {
131
+ const starts = [0, ...headingStarts.filter((start) => start > 0 && start < text.length).toSorted((left, right) => left - right)];
132
+ return starts.map((start, index) => ({ start, end: starts[index + 1] ?? text.length }));
133
+ };
134
+
135
+ /** Every fee the section states: a fee word, then only separators, links and per-person words before an amount. */
136
+ export const feesIn = (
137
+ text: string,
138
+ section: Span,
139
+ amounts: readonly InstallmentAmount[],
140
+ words: InstallmentWords,
141
+ series: InstallmentSeriesWords,
142
+ ): InstallmentAmount[] => {
143
+ const body = text.slice(section.start, section.end);
144
+ return occurrences(body, series.fees).flatMap((span) => {
145
+ const at = { start: section.start + span.start, end: section.start + span.end };
146
+ const sentence = sentenceAround(text, at);
147
+ const amount = amounts.find((candidate) => candidate.offset >= at.end && candidate.end <= sentence.end);
148
+ if (amount === undefined || !isPlainGap(text.slice(at.end, amount.offset), [...words.links, ...series.feeLinks])) return [];
149
+ return [amount];
150
+ });
151
+ };
152
+
153
+ const within = (span: Span, section: Span): boolean => span.start >= section.start && span.end <= section.end;
154
+
155
+ const sameAmount = (left: InstallmentAmount, right: InstallmentAmount): boolean => left.value === right.value && left.currency === right.currency;
156
+
157
+ const partOf = (text: string, amount: InstallmentAmount): Part => ({ count: 1, amount, written: text.slice(amount.offset, amount.end) });
158
+
159
+ /** The comparison of one section: its series of instalments against its one fee; undefined when anything is not read. */
160
+ const sectionIssue = (
161
+ text: string,
162
+ section: Span,
163
+ items: readonly Item[],
164
+ amounts: readonly InstallmentAmount[],
165
+ words: InstallmentWords,
166
+ series: InstallmentSeriesWords,
167
+ ): StructureIssue | undefined => {
168
+ const own = items.filter((item) => within(item.line, section));
169
+ if (!isWholeSeries(own) || !hasWord(text.slice(section.start, section.end), series.payments)) return undefined;
170
+ const fees = feesIn(text, section, amounts, words, series).filter((fee) => !own.some((item) => within({ start: fee.offset, end: fee.end }, item.line)));
171
+ const [fee] = fees;
172
+ if (fee === undefined || !fees.every((other) => sameAmount(other, fee)) || isRough(text, { start: fee.offset, end: fee.end }, words)) return undefined;
173
+ const parts = own.flatMap((item) => (item.amount === undefined ? [] : [partOf(text, item.amount)]));
174
+ const [first] = parts;
175
+ if (first === undefined || parts.some((part) => part.amount.currency !== fee.currency || part.amount.value >= fee.value)) return undefined;
176
+ const written = text.slice(fee.offset, fee.end);
177
+ const sum = sumOf(parts);
178
+ const stated = fee.scale === undefined ? { low: fee.value, high: fee.value } : intervalOf(fee, written);
179
+ if (isTotalOf(stated, sum)) return undefined;
180
+ return { offset: first.amount.offset, values: { payments: describe(parts), sum: writtenLike((sum.low + sum.high) / 2, first), total: written } };
181
+ };
182
+
183
+ /** Whether a deposit is named outside the items of a series: then whether it is part of the fee cannot be read. */
184
+ const depositOutside = (text: string, items: readonly Item[], series: InstallmentSeriesWords): boolean =>
185
+ occurrences(text, series.deposits).some((span) => !items.some((item) => item.number === undefined && within(span, item.line)));
186
+
187
+ /** Every section whose numbered instalments do not add up to its stated fee; reported at the first instalment. */
188
+ export const installmentFeeMismatches = (
189
+ text: string,
190
+ amounts: readonly InstallmentAmount[],
191
+ headingStarts: readonly number[],
192
+ words: InstallmentWords,
193
+ series: InstallmentSeriesWords,
194
+ ): StructureIssue[] => {
195
+ const items = itemLabels(text, series).map((label) => itemOf(text, label, amounts, words, series));
196
+ if (items.length < 2 || depositOutside(text, items, series)) return [];
197
+ return sectionsOf(text, headingStarts).flatMap((section) => {
198
+ const issue = sectionIssue(text, section, items, amounts, words, series);
199
+ return issue === undefined ? [] : [issue];
200
+ });
201
+ };
@@ -87,7 +87,7 @@ const wordPattern = (word: string): string => {
87
87
  };
88
88
 
89
89
  /** One alternation of the words, longest first so 初回お支払額 wins over 初回. Undefined when there are none. */
90
- const alternation = (words: readonly string[]): string | undefined =>
90
+ export const alternation = (words: readonly string[]): string | undefined =>
91
91
  words.length === 0
92
92
  ? undefined
93
93
  : words
@@ -95,7 +95,7 @@ const alternation = (words: readonly string[]): string | undefined =>
95
95
  .map(wordPattern)
96
96
  .join("|");
97
97
 
98
- const occurrences = (text: string, words: readonly string[]): Span[] => {
98
+ export const occurrences = (text: string, words: readonly string[]): Span[] => {
99
99
  const pattern = alternation(words);
100
100
  if (pattern === undefined) return [];
101
101
  return [...text.matchAll(new RegExp(pattern, "giu"))].map((match) => ({ start: match.index, end: match.index + match[0].length }));
@@ -109,7 +109,7 @@ const withoutWords = (text: string, words: readonly string[]): string => {
109
109
  /** Whether only separators and these words stand in a gap on one line (「:」, "の", " of "). */
110
110
  export const isPlainGap = (gap: string, words: readonly string[]): boolean => !gap.includes("\n") && withoutWords(gap, words).replace(SEPARATORS, "") === "";
111
111
 
112
- const lineBounds = (text: string, offset: number): Span => {
112
+ export const lineBounds = (text: string, offset: number): Span => {
113
113
  const end = text.indexOf("\n", offset);
114
114
  return { start: text.lastIndexOf("\n", offset - 1) + 1, end: end === -1 ? text.length : end };
115
115
  };
@@ -142,7 +142,7 @@ const countPattern = (words: InstallmentWords): string => {
142
142
  return numberWords === undefined ? DIGITS : `${DIGITS}|${numberWords}`;
143
143
  };
144
144
 
145
- const hasWord = (text: string, words: readonly string[]): boolean => occurrences(text, words).length > 0;
145
+ export const hasWord = (text: string, words: readonly string[]): boolean => occurrences(text, words).length > 0;
146
146
 
147
147
  /** How far around a value its marks are looked for. */
148
148
  const MARK_REACH = 16;
@@ -166,7 +166,7 @@ const opensRange = (text: string, value: Span, connectors: readonly string[]): b
166
166
  return connectors.some((connector) => after.startsWith(connector.toLowerCase()) && /^\s*[$€£¥¥0-90-9]/u.test(after.slice(connector.length)));
167
167
  };
168
168
 
169
- const isRough = (text: string, value: Span, words: InstallmentWords): boolean =>
169
+ export const isRough = (text: string, value: Span, words: InstallmentWords): boolean =>
170
170
  isMarked(text, value, words.markers) || opensRange(text, value, words.connectors);
171
171
 
172
172
  type Label = Span & { readonly kind: LabelKind };
@@ -315,7 +315,7 @@ export const writtenLike = (value: number, part: Part): string => {
315
315
  return part.amount.scale === undefined ? part.written.replace(/[0-90-9][0-90-9,,..]*/u, digits) : digits;
316
316
  };
317
317
 
318
- const describe = (parts: readonly Part[]): string =>
318
+ export const describe = (parts: readonly Part[]): string =>
319
319
  parts.map((part) => (part.count === 1 ? part.written : `${part.written} × ${String(part.count)}`)).join(" + ");
320
320
 
321
321
  const regularOf = (parts: readonly Part[]): Part | undefined => parts.toSorted((left, right) => right.count - left.count)[0];