@sushichan044/kg-core 0.0.0 → 0.3.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/LICENSE +21 -0
- package/README.md +99 -43
- package/dist/index.d.mts +14667 -0
- package/dist/index.mjs +1500 -0
- package/package.json +58 -7
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
//#region src/assert-never.ts
|
|
3
|
+
/**
|
|
4
|
+
* Marks a branch the compiler proved unreachable. Reaching it at runtime means a variant was added
|
|
5
|
+
* to a union without updating the switch that produced this call.
|
|
6
|
+
*/
|
|
7
|
+
function assertNever(value) {
|
|
8
|
+
throw new TypeError(`unhandled variant: ${JSON.stringify(value)}`);
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/namespaced-id.ts
|
|
12
|
+
const NamespacedIdSchema = v.pipe(v.string(), v.nonEmpty(), v.regex(/^[^/]+\/[^/]+$/u, "ID must use namespace/name"), v.brand("NamespacedId"));
|
|
13
|
+
const NamespacedId = {
|
|
14
|
+
schema: NamespacedIdSchema,
|
|
15
|
+
/**
|
|
16
|
+
* For IDs written as literals in code. Throws on a malformed literal, at import time.
|
|
17
|
+
*/
|
|
18
|
+
of: (literal) => v.parse(NamespacedIdSchema, literal),
|
|
19
|
+
/**
|
|
20
|
+
* For IDs supplied by a plugin, where a malformed value is bad input rather than a bug.
|
|
21
|
+
*/
|
|
22
|
+
parse: (raw) => {
|
|
23
|
+
const result = v.safeParse(NamespacedIdSchema, raw);
|
|
24
|
+
return result.success ? result.output : void 0;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/internal/schema.ts
|
|
29
|
+
/**
|
|
30
|
+
* Every DTO in this package is a closed, frozen object: unknown keys are rejected, output is
|
|
31
|
+
* readonly.
|
|
32
|
+
*/
|
|
33
|
+
const readonlyObject = (entries) => v.pipe(v.strictObject(entries), v.readonly());
|
|
34
|
+
const readonlyArray = (item) => v.pipe(v.array(item), v.readonly());
|
|
35
|
+
const nonNegativeInteger = (maximum = Number.POSITIVE_INFINITY) => v.pipe(v.number(), v.finite(), v.integer(), v.minValue(0), v.maxValue(maximum));
|
|
36
|
+
const positiveInteger = () => v.pipe(v.number(), v.finite(), v.integer(), v.minValue(1));
|
|
37
|
+
const TextRange = { schema: v.pipe(readonlyObject({
|
|
38
|
+
start: nonNegativeInteger(),
|
|
39
|
+
end: nonNegativeInteger()
|
|
40
|
+
}), v.check(({ start, end }) => end >= start, "range end must not precede its start")) };
|
|
41
|
+
const DisplayRange = { schema: v.pipe(TextRange.schema, v.brand("DisplayRange")) };
|
|
42
|
+
const GraphemeRange = { schema: v.pipe(TextRange.schema, v.brand("GraphemeRange")) };
|
|
43
|
+
const SourceRange = { schema: v.pipe(TextRange.schema, v.brand("SourceRange")) };
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/range/manuscript-range.ts
|
|
46
|
+
const ManuscriptRangeSchema = readonlyObject({
|
|
47
|
+
source: SourceRange.schema,
|
|
48
|
+
display: DisplayRange.schema,
|
|
49
|
+
graphemes: GraphemeRange.schema
|
|
50
|
+
});
|
|
51
|
+
const ManuscriptRange = {
|
|
52
|
+
schema: ManuscriptRangeSchema,
|
|
53
|
+
/**
|
|
54
|
+
* Brands a span core computed itself. Throws rather than returning a failure: the inputs come
|
|
55
|
+
* from core's own arithmetic, so a violated bound is a bug here, not bad data from a caller.
|
|
56
|
+
*/
|
|
57
|
+
of: (input) => v.parse(ManuscriptRangeSchema, input),
|
|
58
|
+
merge: (ranges) => {
|
|
59
|
+
const present = ranges.filter((range) => range !== null);
|
|
60
|
+
if (present.length === 0) return null;
|
|
61
|
+
return ManuscriptRange.of({
|
|
62
|
+
source: {
|
|
63
|
+
start: Math.min(...present.map(({ source }) => source.start)),
|
|
64
|
+
end: Math.max(...present.map(({ source }) => source.end))
|
|
65
|
+
},
|
|
66
|
+
display: {
|
|
67
|
+
start: Math.min(...present.map(({ display }) => display.start)),
|
|
68
|
+
end: Math.max(...present.map(({ display }) => display.end))
|
|
69
|
+
},
|
|
70
|
+
graphemes: {
|
|
71
|
+
start: Math.min(...present.map(({ graphemes }) => graphemes.start)),
|
|
72
|
+
end: Math.max(...present.map(({ graphemes }) => graphemes.end))
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
overlaps: (range, other) => range.graphemes.start < other.graphemes.end && range.graphemes.end > other.graphemes.start
|
|
77
|
+
};
|
|
78
|
+
const SourcePosition = {
|
|
79
|
+
schema: readonlyObject({
|
|
80
|
+
offset: nonNegativeInteger(),
|
|
81
|
+
line: positiveInteger(),
|
|
82
|
+
column: positiveInteger()
|
|
83
|
+
}),
|
|
84
|
+
/**
|
|
85
|
+
* Counts CRLF, CR and LF as one line break each; column is UTF-16 code units from line start.
|
|
86
|
+
*/
|
|
87
|
+
at: (source, offset) => {
|
|
88
|
+
let line = 1;
|
|
89
|
+
let lineStart = 0;
|
|
90
|
+
let index = 0;
|
|
91
|
+
while (index < offset) if (source[index] === "\r") {
|
|
92
|
+
index += source[index + 1] === "\n" ? 2 : 1;
|
|
93
|
+
line += 1;
|
|
94
|
+
lineStart = index;
|
|
95
|
+
} else if (source[index] === "\n") {
|
|
96
|
+
index += 1;
|
|
97
|
+
line += 1;
|
|
98
|
+
lineStart = index;
|
|
99
|
+
} else index += 1;
|
|
100
|
+
return {
|
|
101
|
+
offset,
|
|
102
|
+
line,
|
|
103
|
+
column: offset - lineStart + 1
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const DiagnosticOrigin = { schema: v.variant("kind", [readonlyObject({
|
|
108
|
+
kind: v.literal("parser"),
|
|
109
|
+
id: NamespacedId.schema
|
|
110
|
+
}), readonlyObject({
|
|
111
|
+
kind: v.literal("rule"),
|
|
112
|
+
id: NamespacedId.schema
|
|
113
|
+
})]) };
|
|
114
|
+
const DiagnosticSeverity = { schema: v.picklist(["warning", "error"]) };
|
|
115
|
+
const ManuscriptDiagnostic = {
|
|
116
|
+
schema: readonlyObject({
|
|
117
|
+
id: v.string(),
|
|
118
|
+
origin: DiagnosticOrigin.schema,
|
|
119
|
+
severity: DiagnosticSeverity.schema,
|
|
120
|
+
message: v.string(),
|
|
121
|
+
range: ManuscriptRange.schema,
|
|
122
|
+
location: readonlyObject({
|
|
123
|
+
start: SourcePosition.schema,
|
|
124
|
+
end: SourcePosition.schema
|
|
125
|
+
})
|
|
126
|
+
}),
|
|
127
|
+
of: ({ source, origin, severity, code, message, range }) => ({
|
|
128
|
+
id: `${origin.kind}:${origin.id}:${code}:${range.source.start}:${range.source.end}`,
|
|
129
|
+
origin,
|
|
130
|
+
severity,
|
|
131
|
+
message,
|
|
132
|
+
range,
|
|
133
|
+
location: {
|
|
134
|
+
start: SourcePosition.at(source, range.source.start),
|
|
135
|
+
end: SourcePosition.at(source, range.source.end)
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
/**
|
|
139
|
+
* Stable order for display: by source span, then by producing plugin.
|
|
140
|
+
*/
|
|
141
|
+
compare: (left, right) => left.range.source.start - right.range.source.start || left.range.source.end - right.range.source.end || left.origin.id.localeCompare(right.origin.id)
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/result/manuscript-result.ts
|
|
145
|
+
const ManuscriptResult = {
|
|
146
|
+
succeed: (value, warnings = []) => ({
|
|
147
|
+
ok: true,
|
|
148
|
+
value,
|
|
149
|
+
warnings
|
|
150
|
+
}),
|
|
151
|
+
fail: (error) => ({
|
|
152
|
+
ok: false,
|
|
153
|
+
error
|
|
154
|
+
}),
|
|
155
|
+
/**
|
|
156
|
+
* Validates a result that crossed a plugin boundary, including the diagnostics it carries.
|
|
157
|
+
*/
|
|
158
|
+
schema: (value, error) => v.variant("ok", [readonlyObject({
|
|
159
|
+
ok: v.literal(true),
|
|
160
|
+
value,
|
|
161
|
+
warnings: readonlyArray(ManuscriptDiagnostic.schema)
|
|
162
|
+
}), readonlyObject({
|
|
163
|
+
ok: v.literal(false),
|
|
164
|
+
error
|
|
165
|
+
})])
|
|
166
|
+
};
|
|
167
|
+
const Rejection = {
|
|
168
|
+
schema: readonlyObject({ reason: v.string() }),
|
|
169
|
+
of: (reason) => ({ reason })
|
|
170
|
+
};
|
|
171
|
+
const ValidationIssue = {
|
|
172
|
+
schema: readonlyObject({
|
|
173
|
+
path: v.string(),
|
|
174
|
+
message: v.string()
|
|
175
|
+
}),
|
|
176
|
+
from: (issues) => issues.map((issue) => ({
|
|
177
|
+
path: (issue.path ?? []).map((item) => String(item.key)).join("."),
|
|
178
|
+
message: issue.message
|
|
179
|
+
}))
|
|
180
|
+
};
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/appearance/font-preset-id.ts
|
|
183
|
+
const FontPresetIdSchema = v.picklist(["mincho", "gothic"]);
|
|
184
|
+
const FontPresetId = {
|
|
185
|
+
schema: FontPresetIdSchema,
|
|
186
|
+
is: (value) => v.is(FontPresetIdSchema, value)
|
|
187
|
+
};
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/appearance/font-size-pt.ts
|
|
190
|
+
const RANGE = {
|
|
191
|
+
min: 6,
|
|
192
|
+
max: 24,
|
|
193
|
+
step: .5
|
|
194
|
+
};
|
|
195
|
+
const FontSizePtSchema = v.pipe(v.number(), v.finite(), v.minValue(RANGE.min), v.maxValue(RANGE.max), v.multipleOf(RANGE.step));
|
|
196
|
+
const FontSizePt = {
|
|
197
|
+
schema: FontSizePtSchema,
|
|
198
|
+
range: RANGE,
|
|
199
|
+
is: (value) => v.is(FontSizePtSchema, value)
|
|
200
|
+
};
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/appearance/paper-size-id.ts
|
|
203
|
+
const PaperSizeIdSchema = v.picklist([
|
|
204
|
+
"a4",
|
|
205
|
+
"a5",
|
|
206
|
+
"a6",
|
|
207
|
+
"jis-b5",
|
|
208
|
+
"jis-b6",
|
|
209
|
+
"shinsho"
|
|
210
|
+
]);
|
|
211
|
+
const PaperSizeId = {
|
|
212
|
+
schema: PaperSizeIdSchema,
|
|
213
|
+
is: (value) => v.is(PaperSizeIdSchema, value)
|
|
214
|
+
};
|
|
215
|
+
const ManuscriptAppearanceSettings = {
|
|
216
|
+
schema: readonlyObject({
|
|
217
|
+
paperSize: PaperSizeId.schema,
|
|
218
|
+
fontSizePt: FontSizePt.schema,
|
|
219
|
+
fontPreset: FontPresetId.schema
|
|
220
|
+
}),
|
|
221
|
+
defaults: {
|
|
222
|
+
paperSize: "a5",
|
|
223
|
+
fontSizePt: 9,
|
|
224
|
+
fontPreset: "mincho"
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/appearance/font-preset.ts
|
|
229
|
+
const FONT_PRESETS = {
|
|
230
|
+
mincho: {
|
|
231
|
+
id: "mincho",
|
|
232
|
+
label: "明朝",
|
|
233
|
+
family: "\"Yu Mincho\", \"Hiragino Mincho ProN\", \"Hiragino Mincho Pro\", serif"
|
|
234
|
+
},
|
|
235
|
+
gothic: {
|
|
236
|
+
id: "gothic",
|
|
237
|
+
label: "ゴシック",
|
|
238
|
+
family: "\"Yu Gothic\", \"Hiragino Kaku Gothic ProN\", system-ui, sans-serif"
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const FontPreset = {
|
|
242
|
+
of: (id) => FONT_PRESETS[id],
|
|
243
|
+
all: Object.values(FONT_PRESETS)
|
|
244
|
+
};
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/appearance/length.ts
|
|
247
|
+
const PT_PER_MM = 72 / 25.4;
|
|
248
|
+
function ptToMm(pt) {
|
|
249
|
+
return pt / PT_PER_MM;
|
|
250
|
+
}
|
|
251
|
+
function mmToPt(mm) {
|
|
252
|
+
return mm * PT_PER_MM;
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/appearance/paper-size.ts
|
|
256
|
+
const PAPER_SIZES = {
|
|
257
|
+
a4: {
|
|
258
|
+
id: "a4",
|
|
259
|
+
label: "A4",
|
|
260
|
+
widthMm: 210,
|
|
261
|
+
heightMm: 297
|
|
262
|
+
},
|
|
263
|
+
a5: {
|
|
264
|
+
id: "a5",
|
|
265
|
+
label: "A5",
|
|
266
|
+
widthMm: 148,
|
|
267
|
+
heightMm: 210
|
|
268
|
+
},
|
|
269
|
+
a6: {
|
|
270
|
+
id: "a6",
|
|
271
|
+
label: "A6(文庫)",
|
|
272
|
+
widthMm: 105,
|
|
273
|
+
heightMm: 148
|
|
274
|
+
},
|
|
275
|
+
"jis-b5": {
|
|
276
|
+
id: "jis-b5",
|
|
277
|
+
label: "B5(JIS)",
|
|
278
|
+
widthMm: 182,
|
|
279
|
+
heightMm: 257
|
|
280
|
+
},
|
|
281
|
+
"jis-b6": {
|
|
282
|
+
id: "jis-b6",
|
|
283
|
+
label: "B6(JIS)",
|
|
284
|
+
widthMm: 128,
|
|
285
|
+
heightMm: 182
|
|
286
|
+
},
|
|
287
|
+
shinsho: {
|
|
288
|
+
id: "shinsho",
|
|
289
|
+
label: "新書",
|
|
290
|
+
widthMm: 106,
|
|
291
|
+
heightMm: 173
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const PaperSize = {
|
|
295
|
+
of: (id) => PAPER_SIZES[id],
|
|
296
|
+
all: Object.values(PAPER_SIZES)
|
|
297
|
+
};
|
|
298
|
+
const BoldAnnotation = { schema: readonlyObject({
|
|
299
|
+
kind: v.literal("bold"),
|
|
300
|
+
range: ManuscriptRange.schema
|
|
301
|
+
}) };
|
|
302
|
+
const EmphasisAnnotation = { schema: readonlyObject({
|
|
303
|
+
kind: v.literal("emphasis"),
|
|
304
|
+
range: ManuscriptRange.schema,
|
|
305
|
+
mark: v.string()
|
|
306
|
+
}) };
|
|
307
|
+
const ItalicAnnotation = { schema: readonlyObject({
|
|
308
|
+
kind: v.literal("italic"),
|
|
309
|
+
range: ManuscriptRange.schema
|
|
310
|
+
}) };
|
|
311
|
+
const RubyAnnotation = { schema: readonlyObject({
|
|
312
|
+
kind: v.literal("ruby"),
|
|
313
|
+
range: ManuscriptRange.schema,
|
|
314
|
+
reading: v.string()
|
|
315
|
+
}) };
|
|
316
|
+
const ManuscriptAnnotation = {
|
|
317
|
+
schema: v.variant("kind", [
|
|
318
|
+
RubyAnnotation.schema,
|
|
319
|
+
BoldAnnotation.schema,
|
|
320
|
+
ItalicAnnotation.schema,
|
|
321
|
+
EmphasisAnnotation.schema
|
|
322
|
+
]),
|
|
323
|
+
overlapping: (annotations, range) => annotations.filter((annotation) => ManuscriptRange.overlaps(annotation.range, range)),
|
|
324
|
+
/**
|
|
325
|
+
* Stable order for rendering: outermost span first, then by variant name.
|
|
326
|
+
*/
|
|
327
|
+
compare: (left, right) => left.range.source.start - right.range.source.start || left.range.source.end - right.range.source.end || left.kind.localeCompare(right.kind)
|
|
328
|
+
};
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/internal/describe-cause.ts
|
|
331
|
+
/**
|
|
332
|
+
* Renders an unknown throwable for display, without assuming it is an Error.
|
|
333
|
+
*/
|
|
334
|
+
function describeCause(cause) {
|
|
335
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/parser/parse-error.ts
|
|
339
|
+
const ParseError = {
|
|
340
|
+
/**
|
|
341
|
+
* Human-readable rendering. Branch on `kind` instead when the caller needs to react.
|
|
342
|
+
*/
|
|
343
|
+
describe: (error) => {
|
|
344
|
+
switch (error.kind) {
|
|
345
|
+
case "InvalidParserId": return `パーサー ID "${error.parserId}" は namespace/name の形式ではありません`;
|
|
346
|
+
case "InvalidParserOutput": return `パーサー ${error.parserId} の出力が契約を満たしていません: ${error.issues.map(({ message }) => message).join(", ")}`;
|
|
347
|
+
case "ParserRejected": return `パーサー ${error.parserId} が解析を中止しました: ${error.reason}`;
|
|
348
|
+
case "ParserThrew": return `パーサー ${error.parserId} が例外を投げました: ${describeCause(error.cause)}`;
|
|
349
|
+
default: return assertNever(error);
|
|
350
|
+
}
|
|
351
|
+
} };
|
|
352
|
+
const ParsedGrapheme = { schema: readonlyObject({
|
|
353
|
+
value: v.string(),
|
|
354
|
+
range: ManuscriptRange.schema
|
|
355
|
+
}) };
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region src/parser/parsed-manuscript.ts
|
|
358
|
+
const ParsedManuscriptSchema = readonlyObject({
|
|
359
|
+
source: v.string(),
|
|
360
|
+
displayText: v.string(),
|
|
361
|
+
graphemes: readonlyArray(ParsedGrapheme.schema),
|
|
362
|
+
annotations: readonlyArray(ManuscriptAnnotation.schema)
|
|
363
|
+
});
|
|
364
|
+
function rangeFits(range, manuscript) {
|
|
365
|
+
return range.source.end <= manuscript.source.length && range.display.end <= manuscript.displayText.length && range.graphemes.end <= manuscript.graphemes.length;
|
|
366
|
+
}
|
|
367
|
+
function isConsistent(manuscript, source) {
|
|
368
|
+
const joined = manuscript.graphemes.map(({ value }) => value).join("");
|
|
369
|
+
const sequential = manuscript.graphemes.every(({ range }, index) => range.graphemes.start === index && range.graphemes.end === index + 1 && rangeFits(range, manuscript));
|
|
370
|
+
return manuscript.source === source && manuscript.displayText === joined && sequential && manuscript.annotations.every(({ range }) => rangeFits(range, manuscript));
|
|
371
|
+
}
|
|
372
|
+
const CONTRACT_MESSAGE = "parser output must contain consistent source, display, grapheme, and annotation ranges";
|
|
373
|
+
const ParsedManuscript = {
|
|
374
|
+
schema: ParsedManuscriptSchema,
|
|
375
|
+
/**
|
|
376
|
+
* The parser contract as a schema: shape, plus the cross-field invariants that make the three
|
|
377
|
+
* coordinate systems line up. Annotations come out in a stable order regardless of parser.
|
|
378
|
+
*/
|
|
379
|
+
contractFor: (source) => v.pipe(ParsedManuscriptSchema, v.check((manuscript) => isConsistent(manuscript, source), CONTRACT_MESSAGE), v.transform((manuscript) => ({
|
|
380
|
+
...manuscript,
|
|
381
|
+
annotations: [...manuscript.annotations].sort(ManuscriptAnnotation.compare)
|
|
382
|
+
})))
|
|
383
|
+
};
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/internal/segmenter.ts
|
|
386
|
+
/**
|
|
387
|
+
* Shared across parsing and proofreading so both agree on what counts as one grapheme.
|
|
388
|
+
*/
|
|
389
|
+
const graphemeSegmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/parser/internal/manuscript-draft.ts
|
|
392
|
+
/**
|
|
393
|
+
* Text appended in separate calls can still form one grapheme (a base character followed by a
|
|
394
|
+
* combining mark). When that happens the trailing part is folded into the previous grapheme.
|
|
395
|
+
*/
|
|
396
|
+
function joinedPrefixLength(previous, text) {
|
|
397
|
+
const [first] = graphemeSegmenter.segment(previous + text);
|
|
398
|
+
return (first?.segment.length ?? previous.length) - previous.length;
|
|
399
|
+
}
|
|
400
|
+
function createManuscriptDraft(source) {
|
|
401
|
+
const displayParts = [];
|
|
402
|
+
const graphemes = [];
|
|
403
|
+
const annotations = [];
|
|
404
|
+
let displayLength = 0;
|
|
405
|
+
const append = (text, sourceStart) => {
|
|
406
|
+
const displayStart = displayLength;
|
|
407
|
+
let graphemeStart = graphemes.length;
|
|
408
|
+
let unsegmentedStart = 0;
|
|
409
|
+
displayParts.push(text);
|
|
410
|
+
const previous = graphemes.at(-1);
|
|
411
|
+
if (previous !== void 0 && text.length > 0) {
|
|
412
|
+
unsegmentedStart = joinedPrefixLength(previous.value, text);
|
|
413
|
+
if (unsegmentedStart > 0) {
|
|
414
|
+
previous.value += text.slice(0, unsegmentedStart);
|
|
415
|
+
previous.range.display.end += unsegmentedStart;
|
|
416
|
+
previous.range.source.end = sourceStart + unsegmentedStart;
|
|
417
|
+
graphemeStart -= 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const { index, segment } of graphemeSegmenter.segment(text.slice(unsegmentedStart))) {
|
|
421
|
+
const offset = unsegmentedStart + index;
|
|
422
|
+
const graphemeIndex = graphemes.length;
|
|
423
|
+
graphemes.push({
|
|
424
|
+
value: segment,
|
|
425
|
+
range: {
|
|
426
|
+
source: {
|
|
427
|
+
start: sourceStart + offset,
|
|
428
|
+
end: sourceStart + offset + segment.length
|
|
429
|
+
},
|
|
430
|
+
display: {
|
|
431
|
+
start: displayStart + offset,
|
|
432
|
+
end: displayStart + offset + segment.length
|
|
433
|
+
},
|
|
434
|
+
graphemes: {
|
|
435
|
+
start: graphemeIndex,
|
|
436
|
+
end: graphemeIndex + 1
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
displayLength += text.length;
|
|
442
|
+
return {
|
|
443
|
+
source: {
|
|
444
|
+
start: sourceStart,
|
|
445
|
+
end: sourceStart + text.length
|
|
446
|
+
},
|
|
447
|
+
display: {
|
|
448
|
+
start: displayStart,
|
|
449
|
+
end: displayLength
|
|
450
|
+
},
|
|
451
|
+
graphemes: {
|
|
452
|
+
start: Math.max(0, graphemeStart),
|
|
453
|
+
end: graphemes.length
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
append,
|
|
459
|
+
annotate: (annotation) => {
|
|
460
|
+
annotations.push(annotation);
|
|
461
|
+
},
|
|
462
|
+
build: () => v.parse(ParsedManuscript.schema, {
|
|
463
|
+
source,
|
|
464
|
+
displayText: displayParts.join(""),
|
|
465
|
+
graphemes,
|
|
466
|
+
annotations
|
|
467
|
+
})
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/parser/plain-text-parser.ts
|
|
472
|
+
/**
|
|
473
|
+
* Treats the source verbatim: no notation, no annotations, display text equals source.
|
|
474
|
+
*/
|
|
475
|
+
const plainTextParser = {
|
|
476
|
+
id: "kg/plain-text",
|
|
477
|
+
parse: (source) => {
|
|
478
|
+
const draft = createManuscriptDraft(source);
|
|
479
|
+
draft.append(source, 0);
|
|
480
|
+
return ManuscriptResult.succeed(draft.build());
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/parser/parse-manuscript.ts
|
|
485
|
+
function parseManuscript(source, options = {}) {
|
|
486
|
+
const parser = options.parser ?? plainTextParser;
|
|
487
|
+
const parserId = NamespacedId.parse(parser.id);
|
|
488
|
+
if (parserId === void 0) return ManuscriptResult.fail({
|
|
489
|
+
kind: "InvalidParserId",
|
|
490
|
+
parserId: parser.id
|
|
491
|
+
});
|
|
492
|
+
let raw;
|
|
493
|
+
try {
|
|
494
|
+
raw = parser.parse(source);
|
|
495
|
+
} catch (cause) {
|
|
496
|
+
return ManuscriptResult.fail({
|
|
497
|
+
kind: "ParserThrew",
|
|
498
|
+
parserId,
|
|
499
|
+
cause
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const envelope = v.safeParse(ManuscriptResult.schema(ParsedManuscript.contractFor(source), Rejection.schema), raw);
|
|
503
|
+
if (!envelope.success) return ManuscriptResult.fail({
|
|
504
|
+
kind: "InvalidParserOutput",
|
|
505
|
+
parserId,
|
|
506
|
+
issues: ValidationIssue.from(envelope.issues)
|
|
507
|
+
});
|
|
508
|
+
const result = envelope.output;
|
|
509
|
+
return result.ok ? result : ManuscriptResult.fail({
|
|
510
|
+
kind: "ParserRejected",
|
|
511
|
+
parserId,
|
|
512
|
+
reason: result.error.reason
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/parser/internal/pixiv-notation.ts
|
|
517
|
+
function trimContent(text, sourceStart) {
|
|
518
|
+
const trimmedStart = text.trimStart();
|
|
519
|
+
const leadingLength = text.length - trimmedStart.length;
|
|
520
|
+
return {
|
|
521
|
+
text: trimmedStart.trimEnd(),
|
|
522
|
+
sourceStart: sourceStart + leadingLength
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Tags never span lines, so a candidate closing delimiter past the line end does not count.
|
|
527
|
+
*/
|
|
528
|
+
function lineEnd(source, start) {
|
|
529
|
+
const lf = source.indexOf("\n", start);
|
|
530
|
+
const cr = source.indexOf("\r", start);
|
|
531
|
+
if (lf === -1) return cr === -1 ? source.length : cr;
|
|
532
|
+
if (cr === -1) return lf;
|
|
533
|
+
return Math.min(lf, cr);
|
|
534
|
+
}
|
|
535
|
+
function closingIndex(source, start, closing) {
|
|
536
|
+
const end = source.indexOf(closing, start);
|
|
537
|
+
return end !== -1 && end < lineEnd(source, start) ? end : void 0;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Tag bodies hold literal text only; a nested bracket means this is not a well-formed tag.
|
|
541
|
+
*/
|
|
542
|
+
function isFlatContent(text) {
|
|
543
|
+
return text.length > 0 && !text.includes("[") && !text.includes("]");
|
|
544
|
+
}
|
|
545
|
+
function splitPair(body, bodySourceStart) {
|
|
546
|
+
const separator = body.indexOf(">");
|
|
547
|
+
if (separator === -1 || body.indexOf(">", separator + 1) !== -1) return void 0;
|
|
548
|
+
const left = trimContent(body.slice(0, separator), bodySourceStart);
|
|
549
|
+
const right = trimContent(body.slice(separator + 1), bodySourceStart + separator + 1);
|
|
550
|
+
return isFlatContent(left.text) && isFlatContent(right.text) ? [left, right] : void 0;
|
|
551
|
+
}
|
|
552
|
+
function parsePairTag(source, start, prefix) {
|
|
553
|
+
if (!source.startsWith(prefix, start)) return void 0;
|
|
554
|
+
const bodyStart = start + prefix.length;
|
|
555
|
+
const closing = closingIndex(source, bodyStart, "]]");
|
|
556
|
+
if (closing === void 0) return void 0;
|
|
557
|
+
const pair = splitPair(source.slice(bodyStart, closing), bodyStart);
|
|
558
|
+
if (pair === void 0) return void 0;
|
|
559
|
+
const [content, value] = pair;
|
|
560
|
+
if (prefix === "[[rb:") return {
|
|
561
|
+
end: closing + 2,
|
|
562
|
+
content,
|
|
563
|
+
annotation: {
|
|
564
|
+
kind: "ruby",
|
|
565
|
+
reading: value.text
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
if ([...graphemeSegmenter.segment(value.text)].length !== 1) return void 0;
|
|
569
|
+
return {
|
|
570
|
+
end: closing + 2,
|
|
571
|
+
content,
|
|
572
|
+
annotation: {
|
|
573
|
+
kind: "emphasis",
|
|
574
|
+
mark: value.text
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function parseStyledTag(source, start, prefix) {
|
|
579
|
+
if (!source.startsWith(prefix, start)) return void 0;
|
|
580
|
+
const bodyStart = start + prefix.length;
|
|
581
|
+
const closing = closingIndex(source, bodyStart, "]");
|
|
582
|
+
if (closing === void 0) return void 0;
|
|
583
|
+
const content = {
|
|
584
|
+
text: source.slice(bodyStart, closing),
|
|
585
|
+
sourceStart: bodyStart
|
|
586
|
+
};
|
|
587
|
+
if (!isFlatContent(content.text)) return void 0;
|
|
588
|
+
return {
|
|
589
|
+
end: closing + 1,
|
|
590
|
+
content,
|
|
591
|
+
annotation: { kind: prefix === "[b:" ? "bold" : "italic" }
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function parsePixivTag(source, start) {
|
|
595
|
+
return parsePairTag(source, start, "[[rb:") ?? parsePairTag(source, start, "[[emphasismark:") ?? parseStyledTag(source, start, "[b:") ?? parseStyledTag(source, start, "[i:");
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* How far an unrecognised `[` run extends before it can be handed back to the reader as text.
|
|
599
|
+
*/
|
|
600
|
+
function literalEnd(source, start) {
|
|
601
|
+
const endOfLine = lineEnd(source, start);
|
|
602
|
+
const closing = source.indexOf("]", start + 1);
|
|
603
|
+
if (closing !== -1 && closing < endOfLine) return closing + 1;
|
|
604
|
+
return Math.max(start + 1, endOfLine);
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/parser/pixiv-parser.ts
|
|
608
|
+
const PARSER_ID = NamespacedId.of("kg/pixiv");
|
|
609
|
+
/**
|
|
610
|
+
* Locates an unrecognised source span in display and grapheme coordinates for the warning.
|
|
611
|
+
*/
|
|
612
|
+
function spanRange(manuscript, span) {
|
|
613
|
+
const covered = manuscript.graphemes.filter(({ range }) => range.source.start < span.end && range.source.end > span.start);
|
|
614
|
+
const first = covered[0];
|
|
615
|
+
const last = covered.at(-1);
|
|
616
|
+
if (first === void 0 || last === void 0) return ManuscriptRange.of({
|
|
617
|
+
source: span,
|
|
618
|
+
display: {
|
|
619
|
+
start: 0,
|
|
620
|
+
end: 0
|
|
621
|
+
},
|
|
622
|
+
graphemes: {
|
|
623
|
+
start: 0,
|
|
624
|
+
end: 0
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
return ManuscriptRange.of({
|
|
628
|
+
source: span,
|
|
629
|
+
display: {
|
|
630
|
+
start: first.range.display.start,
|
|
631
|
+
end: last.range.display.end
|
|
632
|
+
},
|
|
633
|
+
graphemes: {
|
|
634
|
+
start: first.range.graphemes.start,
|
|
635
|
+
end: last.range.graphemes.end
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
function unrecognizedNotationWarning(source, manuscript, span, index) {
|
|
640
|
+
return ManuscriptDiagnostic.of({
|
|
641
|
+
source,
|
|
642
|
+
origin: {
|
|
643
|
+
kind: "parser",
|
|
644
|
+
id: PARSER_ID
|
|
645
|
+
},
|
|
646
|
+
severity: "warning",
|
|
647
|
+
code: `unrecognized-notation-${index}`,
|
|
648
|
+
message: "認識できない記法を原文として扱いました",
|
|
649
|
+
range: spanRange(manuscript, span)
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Understands Pixiv's ruby, bold, italic and emphasis-mark notation. Anything bracket-shaped that
|
|
654
|
+
* does not parse is kept as literal text and reported as a warning rather than dropped.
|
|
655
|
+
*/
|
|
656
|
+
const pixivParser = {
|
|
657
|
+
id: PARSER_ID,
|
|
658
|
+
parse: (source) => {
|
|
659
|
+
const draft = createManuscriptDraft(source);
|
|
660
|
+
const unrecognized = [];
|
|
661
|
+
let index = 0;
|
|
662
|
+
while (index < source.length) {
|
|
663
|
+
if (source[index] !== "[") {
|
|
664
|
+
const nextTag = source.indexOf("[", index);
|
|
665
|
+
const end = nextTag === -1 ? source.length : nextTag;
|
|
666
|
+
draft.append(source.slice(index, end), index);
|
|
667
|
+
index = end;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const tag = parsePixivTag(source, index);
|
|
671
|
+
if (tag !== void 0) {
|
|
672
|
+
const range = draft.append(tag.content.text, tag.content.sourceStart);
|
|
673
|
+
draft.annotate({
|
|
674
|
+
...tag.annotation,
|
|
675
|
+
range: {
|
|
676
|
+
...range,
|
|
677
|
+
source: {
|
|
678
|
+
start: index,
|
|
679
|
+
end: tag.end
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
index = tag.end;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const end = literalEnd(source, index);
|
|
687
|
+
draft.append(source.slice(index, end), index);
|
|
688
|
+
unrecognized.push({
|
|
689
|
+
start: index,
|
|
690
|
+
end
|
|
691
|
+
});
|
|
692
|
+
index = end;
|
|
693
|
+
}
|
|
694
|
+
const manuscript = draft.build();
|
|
695
|
+
return ManuscriptResult.succeed(manuscript, unrecognized.map((span, order) => unrecognizedNotationWarning(source, manuscript, span, order)));
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/composer/compose-error.ts
|
|
700
|
+
const ComposeError = {
|
|
701
|
+
/**
|
|
702
|
+
* Human-readable rendering. Branch on `kind` instead when the caller needs to react.
|
|
703
|
+
*/
|
|
704
|
+
describe: (error) => {
|
|
705
|
+
switch (error.kind) {
|
|
706
|
+
case "InvalidComposerId": return `組版エンジン ID "${error.composerId}" は namespace/name の形式ではありません`;
|
|
707
|
+
case "InvalidManuscript": return `解析済み原稿が不正です: ${joinIssues$1(error.issues)}`;
|
|
708
|
+
case "InvalidSettings": return `組版設定が ${error.composerId} の要求を満たしていません: ${joinIssues$1(error.issues)}`;
|
|
709
|
+
case "InvalidComposerOutput": return `組版エンジン ${error.composerId} の出力が契約を満たしていません: ${joinIssues$1(error.issues)}`;
|
|
710
|
+
case "ComposerRejected": return `組版エンジン ${error.composerId} が組版を中止しました: ${error.reason}`;
|
|
711
|
+
case "ComposerThrew": return `組版エンジン ${error.composerId} が例外を投げました: ${describeCause(error.cause)}`;
|
|
712
|
+
default: return assertNever(error);
|
|
713
|
+
}
|
|
714
|
+
} };
|
|
715
|
+
function joinIssues$1(issues) {
|
|
716
|
+
return issues.map(({ message }) => message).join(", ");
|
|
717
|
+
}
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/composer/compose-manuscript.ts
|
|
720
|
+
function composeManuscript(parsed, options) {
|
|
721
|
+
const { composer } = options;
|
|
722
|
+
const manuscript = v.safeParse(ParsedManuscript.schema, parsed);
|
|
723
|
+
if (!manuscript.success) return ManuscriptResult.fail({
|
|
724
|
+
kind: "InvalidManuscript",
|
|
725
|
+
issues: ValidationIssue.from(manuscript.issues)
|
|
726
|
+
});
|
|
727
|
+
const composerId = NamespacedId.parse(composer.id);
|
|
728
|
+
if (composerId === void 0) return ManuscriptResult.fail({
|
|
729
|
+
kind: "InvalidComposerId",
|
|
730
|
+
composerId: composer.id
|
|
731
|
+
});
|
|
732
|
+
const settings = v.safeParse(composer.settingsSchema, options.settings);
|
|
733
|
+
if (!settings.success) return ManuscriptResult.fail({
|
|
734
|
+
kind: "InvalidSettings",
|
|
735
|
+
composerId,
|
|
736
|
+
issues: ValidationIssue.from(settings.issues)
|
|
737
|
+
});
|
|
738
|
+
let raw;
|
|
739
|
+
try {
|
|
740
|
+
raw = composer.compose(manuscript.output, settings.output);
|
|
741
|
+
} catch (cause) {
|
|
742
|
+
return ManuscriptResult.fail({
|
|
743
|
+
kind: "ComposerThrew",
|
|
744
|
+
composerId,
|
|
745
|
+
cause
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
const envelope = v.safeParse(ManuscriptResult.schema(composer.layoutSchema, Rejection.schema), raw);
|
|
749
|
+
if (!envelope.success) return ManuscriptResult.fail({
|
|
750
|
+
kind: "InvalidComposerOutput",
|
|
751
|
+
composerId,
|
|
752
|
+
issues: ValidationIssue.from(envelope.issues)
|
|
753
|
+
});
|
|
754
|
+
const result = envelope.output;
|
|
755
|
+
if (!result.ok) return ManuscriptResult.fail({
|
|
756
|
+
kind: "ComposerRejected",
|
|
757
|
+
composerId,
|
|
758
|
+
reason: result.error.reason
|
|
759
|
+
});
|
|
760
|
+
return ManuscriptResult.succeed({
|
|
761
|
+
composerId,
|
|
762
|
+
parsed: manuscript.output,
|
|
763
|
+
settings: settings.output,
|
|
764
|
+
layout: result.value
|
|
765
|
+
}, result.warnings);
|
|
766
|
+
}
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region src/composer/grid-settings.ts
|
|
769
|
+
const RANGES = {
|
|
770
|
+
charsPerLine: {
|
|
771
|
+
min: 10,
|
|
772
|
+
max: 60
|
|
773
|
+
},
|
|
774
|
+
linesPerStage: {
|
|
775
|
+
min: 10,
|
|
776
|
+
max: 60
|
|
777
|
+
},
|
|
778
|
+
stagesPerPage: {
|
|
779
|
+
min: 1,
|
|
780
|
+
max: 3
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
const boundedCount = (bounds) => v.pipe(v.number(), v.finite(), v.integer(), v.minValue(bounds.min), v.maxValue(bounds.max));
|
|
784
|
+
const GridSettings = {
|
|
785
|
+
schema: readonlyObject({
|
|
786
|
+
charsPerLine: boundedCount(RANGES.charsPerLine),
|
|
787
|
+
linesPerStage: boundedCount(RANGES.linesPerStage),
|
|
788
|
+
stagesPerPage: boundedCount(RANGES.stagesPerPage)
|
|
789
|
+
}),
|
|
790
|
+
ranges: RANGES,
|
|
791
|
+
defaults: {
|
|
792
|
+
charsPerLine: 27,
|
|
793
|
+
linesPerStage: 23,
|
|
794
|
+
stagesPerPage: 2
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
const LineOffset = {
|
|
798
|
+
schema: readonlyObject({
|
|
799
|
+
leading: nonNegativeInteger(),
|
|
800
|
+
trailing: nonNegativeInteger()
|
|
801
|
+
}),
|
|
802
|
+
total: (offset) => offset.leading + offset.trailing
|
|
803
|
+
};
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/composer/manuscript-offsets.ts
|
|
806
|
+
const MAX_DOCUMENT_OFFSET = 1e4;
|
|
807
|
+
const ManuscriptOffsets = {
|
|
808
|
+
schema: readonlyObject({
|
|
809
|
+
document: readonlyObject({
|
|
810
|
+
leading: nonNegativeInteger(MAX_DOCUMENT_OFFSET),
|
|
811
|
+
trailing: nonNegativeInteger(MAX_DOCUMENT_OFFSET)
|
|
812
|
+
}),
|
|
813
|
+
page: LineOffset.schema,
|
|
814
|
+
stage: LineOffset.schema
|
|
815
|
+
}),
|
|
816
|
+
maxDocumentOffset: MAX_DOCUMENT_OFFSET,
|
|
817
|
+
defaults: {
|
|
818
|
+
document: {
|
|
819
|
+
leading: 0,
|
|
820
|
+
trailing: 0
|
|
821
|
+
},
|
|
822
|
+
page: {
|
|
823
|
+
leading: 0,
|
|
824
|
+
trailing: 0
|
|
825
|
+
},
|
|
826
|
+
stage: {
|
|
827
|
+
leading: 0,
|
|
828
|
+
trailing: 0
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
/**
|
|
832
|
+
* Stage offsets must leave at least one usable line in the stage.
|
|
833
|
+
*/
|
|
834
|
+
maxStageTotal: (grid) => Math.max(0, grid.linesPerStage - 1),
|
|
835
|
+
/**
|
|
836
|
+
* Page offsets must leave at least one usable line on the page, after stage offsets apply.
|
|
837
|
+
*/
|
|
838
|
+
maxPageTotal: (grid, stage) => Math.max(0, (grid.linesPerStage - LineOffset.total(stage)) * grid.stagesPerPage - 1)
|
|
839
|
+
};
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/composer/composition-settings.ts
|
|
842
|
+
const ManuscriptCompositionSettingsSchema = v.pipe(readonlyObject({
|
|
843
|
+
grid: GridSettings.schema,
|
|
844
|
+
offsets: ManuscriptOffsets.schema,
|
|
845
|
+
appearance: ManuscriptAppearanceSettings.schema
|
|
846
|
+
}), v.check(({ grid, offsets }) => LineOffset.total(offsets.stage) <= ManuscriptOffsets.maxStageTotal(grid) && LineOffset.total(offsets.page) <= ManuscriptOffsets.maxPageTotal(grid, offsets.stage), "page or stage offsets leave no usable manuscript lines"));
|
|
847
|
+
const ManuscriptCompositionSettings = {
|
|
848
|
+
schema: ManuscriptCompositionSettingsSchema,
|
|
849
|
+
defaults: {
|
|
850
|
+
grid: GridSettings.defaults,
|
|
851
|
+
offsets: ManuscriptOffsets.defaults,
|
|
852
|
+
appearance: ManuscriptAppearanceSettings.defaults
|
|
853
|
+
},
|
|
854
|
+
is: (value) => v.is(ManuscriptCompositionSettingsSchema, value)
|
|
855
|
+
};
|
|
856
|
+
const CompositionStatistics = { schema: readonlyObject({
|
|
857
|
+
chars: nonNegativeInteger(),
|
|
858
|
+
sourceLines: nonNegativeInteger(),
|
|
859
|
+
pages: nonNegativeInteger()
|
|
860
|
+
}) };
|
|
861
|
+
const GridCell = {
|
|
862
|
+
schema: readonlyObject({
|
|
863
|
+
value: v.nullable(v.string()),
|
|
864
|
+
range: v.nullable(ManuscriptRange.schema),
|
|
865
|
+
annotations: readonlyArray(ManuscriptAnnotation.schema)
|
|
866
|
+
}),
|
|
867
|
+
empty: () => ({
|
|
868
|
+
value: null,
|
|
869
|
+
range: null,
|
|
870
|
+
annotations: []
|
|
871
|
+
})
|
|
872
|
+
};
|
|
873
|
+
const GridLine = {
|
|
874
|
+
schema: readonlyObject({
|
|
875
|
+
range: v.nullable(ManuscriptRange.schema),
|
|
876
|
+
cells: readonlyArray(GridCell.schema)
|
|
877
|
+
}),
|
|
878
|
+
of: (cells) => ({
|
|
879
|
+
cells,
|
|
880
|
+
range: ManuscriptRange.merge(cells.map(({ range }) => range))
|
|
881
|
+
}),
|
|
882
|
+
/**
|
|
883
|
+
* Pads with empty cells so every line in a stage has the same length.
|
|
884
|
+
*/
|
|
885
|
+
padded: (cells, charsPerLine) => GridLine.of([...cells, ...Array.from({ length: Math.max(0, charsPerLine - cells.length) }, GridCell.empty)])
|
|
886
|
+
};
|
|
887
|
+
const GridStage = {
|
|
888
|
+
schema: readonlyObject({
|
|
889
|
+
range: v.nullable(ManuscriptRange.schema),
|
|
890
|
+
lines: readonlyArray(GridLine.schema)
|
|
891
|
+
}),
|
|
892
|
+
of: (lines) => ({
|
|
893
|
+
lines,
|
|
894
|
+
range: ManuscriptRange.merge(lines.map(({ range }) => range))
|
|
895
|
+
})
|
|
896
|
+
};
|
|
897
|
+
const GridPage = {
|
|
898
|
+
schema: readonlyObject({
|
|
899
|
+
range: v.nullable(ManuscriptRange.schema),
|
|
900
|
+
stages: readonlyArray(GridStage.schema)
|
|
901
|
+
}),
|
|
902
|
+
of: (stages) => ({
|
|
903
|
+
stages,
|
|
904
|
+
range: ManuscriptRange.merge(stages.map(({ range }) => range))
|
|
905
|
+
})
|
|
906
|
+
};
|
|
907
|
+
//#endregion
|
|
908
|
+
//#region src/composer/manuscript-geometry.ts
|
|
909
|
+
const STAGE_GAP_CELLS = 2;
|
|
910
|
+
const LINE_GAP_CELLS = .5;
|
|
911
|
+
const nonNegativeLength = () => v.pipe(v.number(), v.finite(), v.minValue(0));
|
|
912
|
+
const ManuscriptGeometrySchema = readonlyObject({
|
|
913
|
+
paperWidthMm: nonNegativeLength(),
|
|
914
|
+
paperHeightMm: nonNegativeLength(),
|
|
915
|
+
cellSizeMm: nonNegativeLength(),
|
|
916
|
+
lineGapMm: nonNegativeLength(),
|
|
917
|
+
fontSizePt: FontSizePt.schema,
|
|
918
|
+
gridWidthMm: nonNegativeLength(),
|
|
919
|
+
gridHeightMm: nonNegativeLength(),
|
|
920
|
+
marginInlineMm: v.pipe(v.number(), v.finite()),
|
|
921
|
+
marginBlockMm: v.pipe(v.number(), v.finite()),
|
|
922
|
+
fitsPaper: v.boolean()
|
|
923
|
+
});
|
|
924
|
+
/**
|
|
925
|
+
* Every stage stacked vertically, plus the gap between adjacent stages.
|
|
926
|
+
*/
|
|
927
|
+
function gridHeightInCells(grid) {
|
|
928
|
+
return grid.stagesPerPage * grid.charsPerLine + STAGE_GAP_CELLS * (grid.stagesPerPage - 1);
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Every vertical line side by side, plus the gap between adjacent lines.
|
|
932
|
+
*/
|
|
933
|
+
function gridWidthInCells(grid) {
|
|
934
|
+
return grid.linesPerStage + LINE_GAP_CELLS * (grid.linesPerStage - 1);
|
|
935
|
+
}
|
|
936
|
+
const ManuscriptGeometry = {
|
|
937
|
+
schema: ManuscriptGeometrySchema,
|
|
938
|
+
of: (grid, appearance) => {
|
|
939
|
+
const paper = PaperSize.of(appearance.paperSize);
|
|
940
|
+
const cellSizeMm = ptToMm(appearance.fontSizePt);
|
|
941
|
+
const gridWidthMm = gridWidthInCells(grid) * cellSizeMm;
|
|
942
|
+
const gridHeightMm = gridHeightInCells(grid) * cellSizeMm;
|
|
943
|
+
const marginInlineMm = (paper.widthMm - gridWidthMm) / 2;
|
|
944
|
+
const marginBlockMm = (paper.heightMm - gridHeightMm) / 2;
|
|
945
|
+
return {
|
|
946
|
+
paperWidthMm: paper.widthMm,
|
|
947
|
+
paperHeightMm: paper.heightMm,
|
|
948
|
+
cellSizeMm,
|
|
949
|
+
lineGapMm: LINE_GAP_CELLS * cellSizeMm,
|
|
950
|
+
fontSizePt: appearance.fontSizePt,
|
|
951
|
+
gridWidthMm,
|
|
952
|
+
gridHeightMm,
|
|
953
|
+
marginInlineMm,
|
|
954
|
+
marginBlockMm,
|
|
955
|
+
fitsPaper: marginInlineMm >= 0 && marginBlockMm >= 0
|
|
956
|
+
};
|
|
957
|
+
},
|
|
958
|
+
/**
|
|
959
|
+
* The largest step-aligned point size whose grid still fits the paper, clamped to the range.
|
|
960
|
+
*/
|
|
961
|
+
maxFontSizePt: (grid, paperSizeId) => {
|
|
962
|
+
const paper = PaperSize.of(paperSizeId);
|
|
963
|
+
const maxCellSizeMm = Math.min(paper.widthMm / gridWidthInCells(grid), paper.heightMm / gridHeightInCells(grid));
|
|
964
|
+
const steps = Math.floor(mmToPt(maxCellSizeMm) / FontSizePt.range.step + 1e-9);
|
|
965
|
+
const aligned = Math.round(steps * FontSizePt.range.step * 1e3) / 1e3;
|
|
966
|
+
return Math.min(FontSizePt.range.max, Math.max(FontSizePt.range.min, aligned));
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
const ManuscriptGridLayout = { schema: readonlyObject({
|
|
970
|
+
pages: readonlyArray(GridPage.schema),
|
|
971
|
+
geometry: ManuscriptGeometry.schema,
|
|
972
|
+
stats: CompositionStatistics.schema
|
|
973
|
+
}) };
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/composer/grid-composer.ts
|
|
976
|
+
const COMPOSER_ID = NamespacedId.of("kg/grid");
|
|
977
|
+
/**
|
|
978
|
+
* Splits display graphemes on line breaks. A trailing break does not create an empty last line.
|
|
979
|
+
*/
|
|
980
|
+
function displayedLines(graphemes) {
|
|
981
|
+
const lines = [[]];
|
|
982
|
+
let endedWithLineBreak = false;
|
|
983
|
+
for (const grapheme of graphemes) {
|
|
984
|
+
if (grapheme.value === "\n" || grapheme.value === "\r" || grapheme.value === "\r\n") {
|
|
985
|
+
lines.push([]);
|
|
986
|
+
endedWithLineBreak = true;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
lines.at(-1).push(grapheme);
|
|
990
|
+
endedWithLineBreak = false;
|
|
991
|
+
}
|
|
992
|
+
if (lines.length > 1 && endedWithLineBreak) lines.pop();
|
|
993
|
+
return lines;
|
|
994
|
+
}
|
|
995
|
+
function toCells(sourceLine, annotations) {
|
|
996
|
+
return sourceLine.map((grapheme) => ({
|
|
997
|
+
value: grapheme.value,
|
|
998
|
+
range: grapheme.range,
|
|
999
|
+
annotations: ManuscriptAnnotation.overlapping(annotations, grapheme.range)
|
|
1000
|
+
}));
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Wraps one source line across as many grid lines as its length requires.
|
|
1004
|
+
*/
|
|
1005
|
+
function wrapLine(cells, charsPerLine) {
|
|
1006
|
+
if (cells.length === 0) return [GridLine.padded([], charsPerLine)];
|
|
1007
|
+
return Array.from({ length: Math.ceil(cells.length / charsPerLine) }, (_unused, index) => GridLine.padded(cells.slice(index * charsPerLine, (index + 1) * charsPerLine), charsPerLine));
|
|
1008
|
+
}
|
|
1009
|
+
function blankLines(count, charsPerLine) {
|
|
1010
|
+
return Array.from({ length: count }, () => GridLine.padded([], charsPerLine));
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Walks every line position on every page in reading order, handing out content lines only where
|
|
1014
|
+
* the stage and page offsets leave room. Always emits at least one page.
|
|
1015
|
+
*/
|
|
1016
|
+
function buildPages(contentLines, settings) {
|
|
1017
|
+
const { charsPerLine, linesPerStage, stagesPerPage } = settings.grid;
|
|
1018
|
+
const { stage: stageOffset, page: pageOffset } = settings.offsets;
|
|
1019
|
+
const usablePerStage = linesPerStage - LineOffset.total(stageOffset);
|
|
1020
|
+
const usablePerPage = usablePerStage * stagesPerPage;
|
|
1021
|
+
const pages = [];
|
|
1022
|
+
let contentIndex = 0;
|
|
1023
|
+
while (contentIndex < contentLines.length || pages.length === 0) {
|
|
1024
|
+
const stages = [];
|
|
1025
|
+
for (let stageIndex = 0; stageIndex < stagesPerPage; stageIndex += 1) {
|
|
1026
|
+
const lines = [];
|
|
1027
|
+
for (let position = 0; position < linesPerStage; position += 1) {
|
|
1028
|
+
const usableIndex = stageIndex * usablePerStage + position - stageOffset.leading;
|
|
1029
|
+
const withinStage = position >= stageOffset.leading && position < linesPerStage - stageOffset.trailing;
|
|
1030
|
+
const withinPage = usableIndex >= pageOffset.leading && usableIndex < usablePerPage - pageOffset.trailing;
|
|
1031
|
+
const next = contentLines[contentIndex];
|
|
1032
|
+
if (!withinStage || !withinPage || next === void 0) {
|
|
1033
|
+
lines.push(GridLine.padded([], charsPerLine));
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
lines.push(next);
|
|
1037
|
+
contentIndex += 1;
|
|
1038
|
+
}
|
|
1039
|
+
stages.push(GridStage.of(lines));
|
|
1040
|
+
}
|
|
1041
|
+
pages.push(GridPage.of(stages));
|
|
1042
|
+
}
|
|
1043
|
+
return pages;
|
|
1044
|
+
}
|
|
1045
|
+
function composeGrid(manuscript, settings) {
|
|
1046
|
+
const { charsPerLine } = settings.grid;
|
|
1047
|
+
const sourceLines = displayedLines(manuscript.graphemes);
|
|
1048
|
+
const cellLines = sourceLines.map((line) => toCells(line, manuscript.annotations));
|
|
1049
|
+
const manuscriptLines = cellLines.flatMap((cells) => wrapLine(cells, charsPerLine));
|
|
1050
|
+
const pages = buildPages([
|
|
1051
|
+
...blankLines(settings.offsets.document.leading, charsPerLine),
|
|
1052
|
+
...manuscriptLines,
|
|
1053
|
+
...blankLines(settings.offsets.document.trailing, charsPerLine)
|
|
1054
|
+
], settings);
|
|
1055
|
+
return ManuscriptResult.succeed({
|
|
1056
|
+
pages,
|
|
1057
|
+
geometry: ManuscriptGeometry.of(settings.grid, settings.appearance),
|
|
1058
|
+
stats: {
|
|
1059
|
+
chars: cellLines.reduce((total, cells) => total + cells.length, 0),
|
|
1060
|
+
sourceLines: sourceLines.length,
|
|
1061
|
+
pages: pages.length
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Places text on Japanese manuscript paper: vertical lines grouped into stages, stages into pages.
|
|
1067
|
+
* Settings arrive already validated by {@link composeManuscript}, so this never rejects.
|
|
1068
|
+
*/
|
|
1069
|
+
const manuscriptGridComposer = {
|
|
1070
|
+
id: COMPOSER_ID,
|
|
1071
|
+
settingsSchema: ManuscriptCompositionSettings.schema,
|
|
1072
|
+
layoutSchema: ManuscriptGridLayout.schema,
|
|
1073
|
+
compose: composeGrid
|
|
1074
|
+
};
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/proofreading/invalid-rule-options.ts
|
|
1077
|
+
const InvalidRuleOptions = { describe: (error) => `ルール ${error.ruleId} のオプション "${error.option}" が不正です: ${error.issues.map(({ message }) => message).join(", ")}` };
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region src/proofreading/proofread-error.ts
|
|
1080
|
+
const ProofreadError = {
|
|
1081
|
+
/**
|
|
1082
|
+
* Human-readable rendering. Branch on `kind` instead when the caller needs to react.
|
|
1083
|
+
*/
|
|
1084
|
+
describe: (error) => {
|
|
1085
|
+
switch (error.kind) {
|
|
1086
|
+
case "InvalidRuleId": return `ルール ID "${error.ruleId}" は namespace/name の形式ではありません`;
|
|
1087
|
+
case "DuplicateRuleId": return `ルール ID ${error.ruleId} が重複しています`;
|
|
1088
|
+
case "InvalidRuleMetadata": return `ルール ${error.ruleId} の metadata が不正です: ${joinIssues(error.issues)}`;
|
|
1089
|
+
case "InvalidReport": return `ルール ${error.ruleId} の報告が契約を満たしていません: ${joinIssues(error.issues)}`;
|
|
1090
|
+
case "UnknownMessageId": return `ルール ${error.ruleId} が未宣言のメッセージ ID "${error.messageId}" を報告しました`;
|
|
1091
|
+
case "RuleThrew": return `ルール ${error.ruleId} が例外を投げました: ${describeCause(error.cause)}`;
|
|
1092
|
+
default: return assertNever(error);
|
|
1093
|
+
}
|
|
1094
|
+
} };
|
|
1095
|
+
function joinIssues(issues) {
|
|
1096
|
+
return issues.map(({ message }) => message).join(", ");
|
|
1097
|
+
}
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region src/proofreading/internal/interpolate.ts
|
|
1100
|
+
/**
|
|
1101
|
+
* Fills `{{ name }}` placeholders from the report's data; an absent key renders as empty.
|
|
1102
|
+
*/
|
|
1103
|
+
function interpolate(template, data) {
|
|
1104
|
+
return template.replaceAll(/\{\{\s*([^}\s]+)\s*\}\}/gu, (_match, key) => String(data?.[key] ?? ""));
|
|
1105
|
+
}
|
|
1106
|
+
const ProofreadingReport = { schema: readonlyObject({
|
|
1107
|
+
range: ManuscriptRange.schema,
|
|
1108
|
+
messageId: v.string(),
|
|
1109
|
+
data: v.optional(v.pipe(v.record(v.string(), v.union([v.string(), v.number()])), v.readonly()))
|
|
1110
|
+
}) };
|
|
1111
|
+
//#endregion
|
|
1112
|
+
//#region src/proofreading/proofreading-rule.ts
|
|
1113
|
+
const ProofreadingRuleMessagesSchema = v.pipe(v.record(v.string(), v.string()), v.readonly());
|
|
1114
|
+
const ProofreadingRuleMeta = {
|
|
1115
|
+
schema: readonlyObject({
|
|
1116
|
+
id: v.string(),
|
|
1117
|
+
messages: ProofreadingRuleMessagesSchema
|
|
1118
|
+
}),
|
|
1119
|
+
messagesSchema: ProofreadingRuleMessagesSchema
|
|
1120
|
+
};
|
|
1121
|
+
//#endregion
|
|
1122
|
+
//#region src/proofreading/proofread-manuscript.ts
|
|
1123
|
+
const reportsSchema = readonlyArray(ProofreadingReport.schema);
|
|
1124
|
+
function prepareRules(rules) {
|
|
1125
|
+
const prepared = [];
|
|
1126
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1127
|
+
for (const rule of rules) {
|
|
1128
|
+
const id = NamespacedId.parse(rule.meta.id);
|
|
1129
|
+
if (id === void 0) return ManuscriptResult.fail({
|
|
1130
|
+
kind: "InvalidRuleId",
|
|
1131
|
+
ruleId: rule.meta.id
|
|
1132
|
+
});
|
|
1133
|
+
if (seen.has(id)) return ManuscriptResult.fail({
|
|
1134
|
+
kind: "DuplicateRuleId",
|
|
1135
|
+
ruleId: id
|
|
1136
|
+
});
|
|
1137
|
+
const messages = v.safeParse(ProofreadingRuleMeta.messagesSchema, rule.meta.messages);
|
|
1138
|
+
if (!messages.success) return ManuscriptResult.fail({
|
|
1139
|
+
kind: "InvalidRuleMetadata",
|
|
1140
|
+
ruleId: id,
|
|
1141
|
+
issues: ValidationIssue.from(messages.issues)
|
|
1142
|
+
});
|
|
1143
|
+
seen.add(id);
|
|
1144
|
+
prepared.push({
|
|
1145
|
+
id,
|
|
1146
|
+
messages: messages.output,
|
|
1147
|
+
rule
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
return ManuscriptResult.succeed(prepared);
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Runs one rule and returns what it reported. A composed-only rule against a parsed manuscript is
|
|
1154
|
+
* simply skipped: the caller asked for a stage this rule cannot see.
|
|
1155
|
+
*/
|
|
1156
|
+
function collectReports({ id, rule }, parsed, composed) {
|
|
1157
|
+
const reports = [];
|
|
1158
|
+
const context = { report: (report) => {
|
|
1159
|
+
reports.push(report);
|
|
1160
|
+
} };
|
|
1161
|
+
try {
|
|
1162
|
+
switch (rule.kind) {
|
|
1163
|
+
case "parsed":
|
|
1164
|
+
rule.check(parsed, context);
|
|
1165
|
+
break;
|
|
1166
|
+
case "composed":
|
|
1167
|
+
if (composed !== null) rule.check(composed, context);
|
|
1168
|
+
break;
|
|
1169
|
+
default: assertNever(rule);
|
|
1170
|
+
}
|
|
1171
|
+
} catch (cause) {
|
|
1172
|
+
return ManuscriptResult.fail({
|
|
1173
|
+
kind: "RuleThrew",
|
|
1174
|
+
ruleId: id,
|
|
1175
|
+
cause
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
const validated = v.safeParse(reportsSchema, reports);
|
|
1179
|
+
return validated.success ? ManuscriptResult.succeed(validated.output) : ManuscriptResult.fail({
|
|
1180
|
+
kind: "InvalidReport",
|
|
1181
|
+
ruleId: id,
|
|
1182
|
+
issues: ValidationIssue.from(validated.issues)
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
function toDiagnostics({ id, messages }, reports, source) {
|
|
1186
|
+
const diagnostics = [];
|
|
1187
|
+
for (const report of reports) {
|
|
1188
|
+
const template = messages[report.messageId];
|
|
1189
|
+
if (template === void 0) return ManuscriptResult.fail({
|
|
1190
|
+
kind: "UnknownMessageId",
|
|
1191
|
+
ruleId: id,
|
|
1192
|
+
messageId: report.messageId
|
|
1193
|
+
});
|
|
1194
|
+
diagnostics.push(ManuscriptDiagnostic.of({
|
|
1195
|
+
source,
|
|
1196
|
+
origin: {
|
|
1197
|
+
kind: "rule",
|
|
1198
|
+
id
|
|
1199
|
+
},
|
|
1200
|
+
severity: "error",
|
|
1201
|
+
code: report.messageId,
|
|
1202
|
+
message: interpolate(template, report.data),
|
|
1203
|
+
range: report.range
|
|
1204
|
+
}));
|
|
1205
|
+
}
|
|
1206
|
+
return ManuscriptResult.succeed(diagnostics);
|
|
1207
|
+
}
|
|
1208
|
+
function run(parsed, composed, rules) {
|
|
1209
|
+
const prepared = prepareRules(rules);
|
|
1210
|
+
if (!prepared.ok) return prepared;
|
|
1211
|
+
const diagnostics = [];
|
|
1212
|
+
for (const rule of prepared.value) {
|
|
1213
|
+
const reports = collectReports(rule, parsed, composed);
|
|
1214
|
+
if (!reports.ok) return reports;
|
|
1215
|
+
const produced = toDiagnostics(rule, reports.value, parsed.source);
|
|
1216
|
+
if (!produced.ok) return produced;
|
|
1217
|
+
diagnostics.push(...produced.value);
|
|
1218
|
+
}
|
|
1219
|
+
return ManuscriptResult.succeed([...diagnostics].sort(ManuscriptDiagnostic.compare));
|
|
1220
|
+
}
|
|
1221
|
+
function proofreadManuscript(manuscript, options) {
|
|
1222
|
+
return "composerId" in manuscript ? run(manuscript.parsed, manuscript, options.rules) : run(manuscript, null, options.rules);
|
|
1223
|
+
}
|
|
1224
|
+
//#endregion
|
|
1225
|
+
//#region src/proofreading/rules/internal/display-line.ts
|
|
1226
|
+
/**
|
|
1227
|
+
* Splits on CRLF, CR and LF. The text after the last break is always emitted, even when empty.
|
|
1228
|
+
*/
|
|
1229
|
+
function splitDisplayLines(text) {
|
|
1230
|
+
const lines = [];
|
|
1231
|
+
let start = 0;
|
|
1232
|
+
let index = 0;
|
|
1233
|
+
while (index < text.length) {
|
|
1234
|
+
const character = text[index];
|
|
1235
|
+
if (character !== "\n" && character !== "\r") {
|
|
1236
|
+
index += 1;
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
lines.push({
|
|
1240
|
+
text: text.slice(start, index),
|
|
1241
|
+
start
|
|
1242
|
+
});
|
|
1243
|
+
index += character === "\r" && text[index + 1] === "\n" ? 2 : 1;
|
|
1244
|
+
start = index;
|
|
1245
|
+
}
|
|
1246
|
+
lines.push({
|
|
1247
|
+
text: text.slice(start),
|
|
1248
|
+
start
|
|
1249
|
+
});
|
|
1250
|
+
return lines;
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region src/proofreading/rules/internal/rule-range.ts
|
|
1254
|
+
const EMPTY_BOUNDS = {
|
|
1255
|
+
start: 0,
|
|
1256
|
+
end: 0
|
|
1257
|
+
};
|
|
1258
|
+
/**
|
|
1259
|
+
* Turns a span of display text into a full range. The source span is the smallest contiguous run
|
|
1260
|
+
* that covers the display span, so it includes any notation the parser removed in between.
|
|
1261
|
+
*/
|
|
1262
|
+
function displayRange(manuscript, start, end) {
|
|
1263
|
+
const covered = manuscript.graphemes.filter(({ range }) => range.display.start < end && range.display.end > start);
|
|
1264
|
+
const first = covered[0];
|
|
1265
|
+
const last = covered.at(-1);
|
|
1266
|
+
if (first === void 0 || last === void 0) return ManuscriptRange.of({
|
|
1267
|
+
source: EMPTY_BOUNDS,
|
|
1268
|
+
display: {
|
|
1269
|
+
start,
|
|
1270
|
+
end
|
|
1271
|
+
},
|
|
1272
|
+
graphemes: EMPTY_BOUNDS
|
|
1273
|
+
});
|
|
1274
|
+
const merged = ManuscriptRange.merge(covered.map(({ range }) => range));
|
|
1275
|
+
return ManuscriptRange.of({
|
|
1276
|
+
source: {
|
|
1277
|
+
start: first.range.source.start + clampWithin(start - first.range.display.start, first.range),
|
|
1278
|
+
end: end === last.range.display.end ? last.range.source.end : last.range.source.start + clampWithin(end - last.range.display.start, last.range)
|
|
1279
|
+
},
|
|
1280
|
+
display: {
|
|
1281
|
+
start,
|
|
1282
|
+
end
|
|
1283
|
+
},
|
|
1284
|
+
graphemes: merged?.graphemes ?? EMPTY_BOUNDS
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* A display offset inside a grapheme cannot map past that grapheme's own source span.
|
|
1289
|
+
*/
|
|
1290
|
+
function clampWithin(offset, range) {
|
|
1291
|
+
return Math.min(offset, range.source.end - range.source.start);
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Turns a span of source text into a full range, for rules that inspect the raw source.
|
|
1295
|
+
*/
|
|
1296
|
+
function sourceRange(manuscript, source) {
|
|
1297
|
+
const covered = manuscript.graphemes.filter(({ range }) => range.source.start < source.end && range.source.end > source.start);
|
|
1298
|
+
const enclosing = manuscript.annotations.find(({ range }) => range.source.start <= source.start && range.source.end >= source.end);
|
|
1299
|
+
const merged = ManuscriptRange.merge(covered.map(({ range }) => range)) ?? enclosing?.range ?? null;
|
|
1300
|
+
return ManuscriptRange.of({
|
|
1301
|
+
source,
|
|
1302
|
+
display: merged?.display ?? EMPTY_BOUNDS,
|
|
1303
|
+
graphemes: merged?.graphemes ?? EMPTY_BOUNDS
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
1307
|
+
//#region src/proofreading/rules/internal/define-rule.ts
|
|
1308
|
+
const DEFAULT_MESSAGE_ID = "default";
|
|
1309
|
+
/**
|
|
1310
|
+
* Builds a single-message rule; the body reports ranges and the wrapper names the message.
|
|
1311
|
+
*/
|
|
1312
|
+
function defineParsedRule(id, message, check) {
|
|
1313
|
+
return {
|
|
1314
|
+
kind: "parsed",
|
|
1315
|
+
meta: {
|
|
1316
|
+
id,
|
|
1317
|
+
messages: { [DEFAULT_MESSAGE_ID]: message }
|
|
1318
|
+
},
|
|
1319
|
+
check: (manuscript, context) => {
|
|
1320
|
+
check(manuscript, (range) => {
|
|
1321
|
+
context.report({
|
|
1322
|
+
range,
|
|
1323
|
+
messageId: DEFAULT_MESSAGE_ID
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Builds a rule that scans each display line with a sticky pattern. Matching per line rather than
|
|
1331
|
+
* across the whole text keeps findings from spanning a line break.
|
|
1332
|
+
*/
|
|
1333
|
+
function defineMatchRule(options) {
|
|
1334
|
+
return defineParsedRule(options.id, options.message, (manuscript, report) => {
|
|
1335
|
+
for (const line of splitDisplayLines(manuscript.displayText)) {
|
|
1336
|
+
options.pattern.lastIndex = 0;
|
|
1337
|
+
let match = options.pattern.exec(line.text);
|
|
1338
|
+
while (match !== null) {
|
|
1339
|
+
if (options.accept?.(match) ?? true) {
|
|
1340
|
+
const start = line.start + match.index;
|
|
1341
|
+
report(displayRange(manuscript, start, start + match[0].length));
|
|
1342
|
+
}
|
|
1343
|
+
match = options.pattern.exec(line.text);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
//#endregion
|
|
1349
|
+
//#region src/proofreading/rules/even-dash.ts
|
|
1350
|
+
const evenDashRule = () => defineMatchRule({
|
|
1351
|
+
id: "kg/even-dash",
|
|
1352
|
+
pattern: /―+/gu,
|
|
1353
|
+
accept: (match) => match[0].length % 2 === 1,
|
|
1354
|
+
message: "連続するダッシュの数は偶数にしてください"
|
|
1355
|
+
});
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/proofreading/rules/even-ellipsis.ts
|
|
1358
|
+
const evenEllipsisRule = () => defineMatchRule({
|
|
1359
|
+
id: "kg/even-ellipsis",
|
|
1360
|
+
pattern: /…+/gu,
|
|
1361
|
+
accept: (match) => match[0].length % 2 === 1,
|
|
1362
|
+
message: "連続する三点リーダーの数は偶数にしてください"
|
|
1363
|
+
});
|
|
1364
|
+
//#endregion
|
|
1365
|
+
//#region src/proofreading/rules/max-arabic-numeral-digits.ts
|
|
1366
|
+
const RULE_ID$1 = "kg/max-arabic-numeral-digits";
|
|
1367
|
+
const DEFAULT_MAX_DIGITS = 2;
|
|
1368
|
+
const MaxDigitsSchema = v.pipe(v.number(), v.finite(), v.integer(), v.minValue(1));
|
|
1369
|
+
function build$1(maxDigits) {
|
|
1370
|
+
return defineMatchRule({
|
|
1371
|
+
id: RULE_ID$1,
|
|
1372
|
+
pattern: /([0-90-9]+)(?:[..]([0-90-9]+))?/gu,
|
|
1373
|
+
accept: (match) => (match[1]?.length ?? 0) > maxDigits || (match[2]?.length ?? 0) > maxDigits,
|
|
1374
|
+
message: `${maxDigits}桁を超えるアラビア数字が使われています`
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
const maxArabicNumeralDigitsRule = () => build$1(DEFAULT_MAX_DIGITS);
|
|
1378
|
+
function createMaxArabicNumeralDigitsRule(options = {}) {
|
|
1379
|
+
if (options.maxDigits === void 0) return ManuscriptResult.succeed(maxArabicNumeralDigitsRule());
|
|
1380
|
+
const maxDigits = v.safeParse(MaxDigitsSchema, options.maxDigits);
|
|
1381
|
+
if (!maxDigits.success) return ManuscriptResult.fail({
|
|
1382
|
+
kind: "InvalidRuleOptions",
|
|
1383
|
+
ruleId: RULE_ID$1,
|
|
1384
|
+
option: "maxDigits",
|
|
1385
|
+
issues: ValidationIssue.from(maxDigits.issues)
|
|
1386
|
+
});
|
|
1387
|
+
return ManuscriptResult.succeed(build$1(maxDigits.output));
|
|
1388
|
+
}
|
|
1389
|
+
//#endregion
|
|
1390
|
+
//#region src/proofreading/rules/minus-before-number.ts
|
|
1391
|
+
const minusBeforeNumberRule = () => defineMatchRule({
|
|
1392
|
+
id: "kg/minus-before-number",
|
|
1393
|
+
pattern: /−(?![0-90-9〇一二三四五六七八九十])/gu,
|
|
1394
|
+
message: "マイナス記号の直後には数字が必要です"
|
|
1395
|
+
});
|
|
1396
|
+
//#endregion
|
|
1397
|
+
//#region src/proofreading/rules/no-consecutive-choonpu.ts
|
|
1398
|
+
const noConsecutiveChoonpuRule = () => defineMatchRule({
|
|
1399
|
+
id: "kg/no-consecutive-choonpu",
|
|
1400
|
+
pattern: /ーー+/gu,
|
|
1401
|
+
message: "長音符が連続しています"
|
|
1402
|
+
});
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region src/proofreading/rules/no-consecutive-interpunct.ts
|
|
1405
|
+
const noConsecutiveInterpunctRule = () => defineMatchRule({
|
|
1406
|
+
id: "kg/no-consecutive-interpunct",
|
|
1407
|
+
pattern: /・・+/gu,
|
|
1408
|
+
message: "中黒が連続しています"
|
|
1409
|
+
});
|
|
1410
|
+
//#endregion
|
|
1411
|
+
//#region src/proofreading/rules/no-consecutive-punctuation.ts
|
|
1412
|
+
const noConsecutivePunctuationRule = () => defineMatchRule({
|
|
1413
|
+
id: "kg/no-consecutive-punctuation",
|
|
1414
|
+
pattern: /。。+|、、+/gu,
|
|
1415
|
+
message: "句読点が連続しています"
|
|
1416
|
+
});
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/proofreading/rules/paragraph-leading-character.ts
|
|
1419
|
+
const RULE_ID = "kg/paragraph-leading-character";
|
|
1420
|
+
const MESSAGE = "段落の先頭には全角スペースまたは開き括弧が必要です";
|
|
1421
|
+
const DEFAULT_ALLOWED_CHARACTERS = " 「『〖〈《((“\"‘'[[〔{{<<";
|
|
1422
|
+
const AllowedCharactersSchema = v.pipe(v.string(), v.nonEmpty());
|
|
1423
|
+
function build(allowedCharacters) {
|
|
1424
|
+
return defineParsedRule(RULE_ID, MESSAGE, (manuscript, report) => {
|
|
1425
|
+
for (const line of splitDisplayLines(manuscript.displayText)) {
|
|
1426
|
+
const leading = line.text[0];
|
|
1427
|
+
if (leading === void 0 || allowedCharacters.includes(leading)) continue;
|
|
1428
|
+
const length = (line.text.codePointAt(0) ?? 0) > 65535 ? 2 : 1;
|
|
1429
|
+
report(displayRange(manuscript, line.start, line.start + length));
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
const paragraphLeadingCharacterRule = () => build(DEFAULT_ALLOWED_CHARACTERS);
|
|
1434
|
+
function createParagraphLeadingCharacterRule(options = {}) {
|
|
1435
|
+
if (options.allowedCharacters === void 0) return ManuscriptResult.succeed(paragraphLeadingCharacterRule());
|
|
1436
|
+
const allowed = v.safeParse(AllowedCharactersSchema, options.allowedCharacters);
|
|
1437
|
+
if (!allowed.success) return ManuscriptResult.fail({
|
|
1438
|
+
kind: "InvalidRuleOptions",
|
|
1439
|
+
ruleId: RULE_ID,
|
|
1440
|
+
option: "allowedCharacters",
|
|
1441
|
+
issues: ValidationIssue.from(allowed.issues)
|
|
1442
|
+
});
|
|
1443
|
+
return ManuscriptResult.succeed(build(allowed.output));
|
|
1444
|
+
}
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region src/proofreading/rules/punctuation-before-closing-quote.ts
|
|
1447
|
+
const punctuationBeforeClosingQuoteRule = () => defineMatchRule({
|
|
1448
|
+
id: "kg/punctuation-before-closing-quote",
|
|
1449
|
+
pattern: /[。、]+(?=[」』〗〉》))”"’']\]〕}}>>])/gu,
|
|
1450
|
+
message: "閉じ括弧の直前に句読点を置くことはできません"
|
|
1451
|
+
});
|
|
1452
|
+
//#endregion
|
|
1453
|
+
//#region src/proofreading/rules/space-after-question-or-exclamation.ts
|
|
1454
|
+
const spaceAfterQuestionOrExclamationRule = () => defineMatchRule({
|
|
1455
|
+
id: "kg/space-after-question-or-exclamation",
|
|
1456
|
+
pattern: /[?!](?![ ?!」』〗〉》))”"’']\]〕}}>>]|$)/gu,
|
|
1457
|
+
message: "感嘆符または疑問符の直後には空白か閉じ括弧が必要です"
|
|
1458
|
+
});
|
|
1459
|
+
//#endregion
|
|
1460
|
+
//#region src/proofreading/rules/variant-character.ts
|
|
1461
|
+
/**
|
|
1462
|
+
* Mongolian free variation selectors, variation selectors, CJK compatibility ideographs, IVSes.
|
|
1463
|
+
*/
|
|
1464
|
+
function isVariantCharacter(character) {
|
|
1465
|
+
const codePoint = character.codePointAt(0);
|
|
1466
|
+
if (codePoint === void 0) return false;
|
|
1467
|
+
return codePoint >= 6155 && codePoint <= 6157 || codePoint === 6159 || codePoint >= 65024 && codePoint <= 65039 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 194560 && codePoint <= 195103 || codePoint >= 917760 && codePoint <= 917999;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Scans the source rather than the display text: notation removal must not hide a variant.
|
|
1471
|
+
*/
|
|
1472
|
+
const variantCharacterRule = () => defineParsedRule("kg/variant-character", "異体字または字形選択子が使われています", (manuscript, report) => {
|
|
1473
|
+
for (const { index, segment } of graphemeSegmenter.segment(manuscript.source)) if (Array.from(segment).some(isVariantCharacter)) report(sourceRange(manuscript, {
|
|
1474
|
+
start: index,
|
|
1475
|
+
end: index + segment.length
|
|
1476
|
+
}));
|
|
1477
|
+
});
|
|
1478
|
+
//#endregion
|
|
1479
|
+
//#region src/proofreading/rules/default-rules.ts
|
|
1480
|
+
/**
|
|
1481
|
+
* The built-in rule set at its default configuration. Infallible by construction: every rule is
|
|
1482
|
+
* built from constants, so there is no options error for a caller to handle.
|
|
1483
|
+
*/
|
|
1484
|
+
function createDefaultProofreadingRules() {
|
|
1485
|
+
return [
|
|
1486
|
+
paragraphLeadingCharacterRule(),
|
|
1487
|
+
punctuationBeforeClosingQuoteRule(),
|
|
1488
|
+
spaceAfterQuestionOrExclamationRule(),
|
|
1489
|
+
evenEllipsisRule(),
|
|
1490
|
+
evenDashRule(),
|
|
1491
|
+
noConsecutivePunctuationRule(),
|
|
1492
|
+
noConsecutiveInterpunctRule(),
|
|
1493
|
+
noConsecutiveChoonpuRule(),
|
|
1494
|
+
minusBeforeNumberRule(),
|
|
1495
|
+
maxArabicNumeralDigitsRule(),
|
|
1496
|
+
variantCharacterRule()
|
|
1497
|
+
];
|
|
1498
|
+
}
|
|
1499
|
+
//#endregion
|
|
1500
|
+
export { BoldAnnotation, ComposeError, CompositionStatistics, DiagnosticOrigin, DiagnosticSeverity, DisplayRange, EmphasisAnnotation, FontPreset, FontPresetId, FontSizePt, GraphemeRange, GridCell, GridLine, GridPage, GridSettings, GridStage, InvalidRuleOptions, ItalicAnnotation, LineOffset, ManuscriptAnnotation, ManuscriptAppearanceSettings, ManuscriptCompositionSettings, ManuscriptDiagnostic, ManuscriptGeometry, ManuscriptGridLayout, ManuscriptOffsets, ManuscriptRange, ManuscriptResult, NamespacedId, PaperSize, PaperSizeId, ParseError, ParsedGrapheme, ParsedManuscript, ProofreadError, ProofreadingReport, ProofreadingRuleMeta, Rejection, RubyAnnotation, SourcePosition, SourceRange, TextRange, ValidationIssue, assertNever, composeManuscript, createDefaultProofreadingRules, createMaxArabicNumeralDigitsRule, createParagraphLeadingCharacterRule, evenDashRule, evenEllipsisRule, manuscriptGridComposer, maxArabicNumeralDigitsRule, minusBeforeNumberRule, mmToPt, noConsecutiveChoonpuRule, noConsecutiveInterpunctRule, noConsecutivePunctuationRule, paragraphLeadingCharacterRule, parseManuscript, pixivParser, plainTextParser, proofreadManuscript, ptToMm, punctuationBeforeClosingQuoteRule, spaceAfterQuestionOrExclamationRule, variantCharacterRule };
|