@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/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 };