aozora 0.5.0

Aozora Bunko notation parser with incremental document snapshots
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
//! Attribute-major formatting model: one `Format` attribute, typed
//! per-scope sums.
//!
//! Aozora's typographic notations (太字, 傍点, 字下げ, …) each apply at one
//! or more *scopes* — forward-reference (`「X」は太字`), single line
//! (`[#地付き]`), or a paired range / block (`[#ここから太字] …
//! [#ここで太字終わり]`). The legacy model defined the same attribute
//! once per scope (`EmphasisKind::Bold` + `ContainerKind::Bold`), letting an
//! illegal `(attribute, scope)` pair and placeholder close payloads
//! (`steps: ±1`, `width: 0`) be representable.
//!
//! This module separates the two axes:
//!
//! - [`Format`] names the **attribute** once, scope-independent — the source
//!   of the canonical keyword and the attribute-identity tag.
//! - [`ForwardAttr`], [`LineFormat`], [`RegionFormat`] each enumerate **only
//!   the attributes legal at that scope**, so an illegal pair is unrepresentable.
//!   Every scope sum projects back via `fn format() -> Format`.
//! - [`RegionClose`] is the close-marker discriminant; the open
//!   [`RegionFormat`] payload stays authoritative (see [`RegionClose::of`]).
//! - The scalar parameters ([`FontShift`], [`ColumnCount`], [`LineWidth`],
//!   [`Kumi`]) are `NonZero`, so the `0` / `±1` placeholders the close markers
//!   used to carry cannot be constructed.

use core::num::{NonZeroI8, NonZeroU8};

use crate::syntax::{BoutenKind, BoutenPosition, HeadingKind, HeadingStyle};

// ----------------------------------------------------------------------
// Scalar parameters — NonZero so placeholders are unconstructable
// ----------------------------------------------------------------------

/// Signed relative font-size shift (旧 `steps: i8`).
///
/// Positive = 大きな (larger), negative = 小さな (smaller). `NonZero` because
/// a zero-stage shift is not a font-size change — the close marker used to
/// carry a `±1` placeholder, which this type makes unrepresentable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct FontShift(pub NonZeroI8);

impl FontShift {
    /// `true` when the shift enlarges (大きな); `false` when it shrinks (小さな).
    #[must_use]
    pub const fn larger(self) -> bool {
        self.0.get().is_positive()
    }

    /// The unsigned stage count.
    #[must_use]
    pub const fn magnitude(self) -> u8 {
        self.0.get().unsigned_abs()
    }
}

/// An **absolute** font size keyword (`特大文字` / `大文字` / `中文字` /
/// `小文字`), as distinct from the relative `N段階…文字` shift carried by
/// [`FontShift`].
///
/// The 青空文庫 corpus attests this scale in works that name a fixed size
/// rather than a step count (e.g. 暗黒公使's 特大 > 大 > 中 > 本文 > 小
/// headline scheme, and the forward `「X」は小文字`). The variants are ordered
/// largest-to-smallest; `Medium` is one step *below* the surrounding body, so
/// it has no representable [`FontShift`] (`NonZero`) — which is why this is its
/// own type rather than a magnitude.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum AbsoluteSize {
    /// 特大文字 — the largest size.
    ExtraLarge,
    /// 大文字 — large.
    Large,
    /// 中文字 — medium (below body).
    Medium,
    /// 小文字 — small.
    Small,
}

impl AbsoluteSize {
    /// The canonical 青空文庫 keyword for this size.
    #[must_use]
    pub const fn keyword(self) -> &'static str {
        match self {
            Self::ExtraLarge => "特大文字",
            Self::Large => "大文字",
            Self::Medium => "中文字",
            Self::Small => "小文字",
        }
    }
}

/// The style of an enclosure (囲み) — a ruled frame, a box glyph, and so on.
///
/// 青空文庫 attests a family of enclosures that share the [`Format::Framed`]
/// identity and differ only on this style axis: 罫囲み (a ruled frame),
/// 「□」囲み (a box glyph), ○付き文字 (an encircled character), 点線丸囲み (a
/// dotted circle), and 二重罫囲み (a double rule). The non-canonical, corpus-
/// vanishing 枠囲み / 枠囲い / 表罫囲み / ミシン罫囲み are **not** members: the
/// core declines them to `Directive{Unknown}` (lossless) rather than fold their
/// spelling onto [`Self::Rule`] (#435). This mirrors how the 傍点 kinds share
/// [`Format::Bouten`] via [`BoutenKind`].
//
// Deliberately NOT `#[non_exhaustive]`: every classifier / render / serialize
// site must handle each kind explicitly, so a new member is compiler-flagged
// at every site rather than silently folded into a `_` fallback (the §7.6
// param-drop bug class). `Bouten(BoutenKind)` is the template.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub enum EnclosureKind {
    /// 罫囲み — a ruled rectangular frame.
    Rule,
    /// 「□」囲み — a box-glyph enclosure. The glyph is □ (U+25A1); the box is
    /// drawn by the stylesheet, so the glyph is re-emitted only on serialize.
    Box,
    /// ○付き文字 — an encircled character (`「X」は○付き文字`). The circle is
    /// drawn by the stylesheet around the target run.
    Circle,
    /// 点線丸囲み — a dotted circular enclosure (`「X」は点線丸囲み`).
    CircleDotted,
    /// 二重罫囲み — a double-ruled rectangular frame (`「X」は二重罫囲み`).
    DoubleRule,
}

/// The diacritical mark of a forward accent directive
/// (`「X」はアクサン(´)付き` / `…ウムラウト(¨)付き`) — which precomposed
/// accented glyph the single quoted Latin letter maps to.
///
/// The mark *word* (アクサン) does not distinguish acute from grave — the
/// bracketed *symbol* does (´ = U+00B4 vs ` = U+FF40); ウムラウト(¨) (¨ =
/// U+00A8) names the umlaut. Composition reuses the `〔…〕` accent digraph table
/// via [`crate::syntax::accent::compose_accent`], the single authority for the
/// "Latin letter plus diacritic yields a precomposed glyph" mapping;
/// [`Format::AccentDot`] is the sibling combining-dot facility.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub(crate) enum AccentMark {
    /// アクサン(´) — acute accent (`e` → é). Accent-table marker byte `'`.
    Acute,
    /// ウムラウト(¨) — umlaut / diaeresis (`o` → ö). Marker byte `:`.
    Umlaut,
    /// アクサン(`) — grave accent (`e` → è). Marker byte `` ` ``. Corpus-absent
    /// but supported for completeness.
    Grave,
}

/// Number of columns in a 段組 region. `1` is not a multi-column layout, so
/// `NonZero` rules out the degenerate case.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct ColumnCount(pub NonZeroU8);

/// Full-width characters per line (字詰め / 字組み width). `NonZero` because a
/// zero-width line is meaningless — the close marker's `0` placeholder is gone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct LineWidth(pub NonZeroU8);

/// The `L行W字組み` clause: `lines` lines of `width` full-width characters.
///
/// Both `NonZero` — the close marker used to re-emit `lines: 0`, which this
/// type makes unrepresentable (the open side is authoritative on close).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct Kumi {
    /// Line count `L` from the `L行` clause.
    pub lines: NonZeroU8,
    /// Full-width characters per line `W` from the `W字組み` clause.
    pub width: NonZeroU8,
}

/// Secondary line-layout clause of an indent block (`、N字詰め` / `、L行W字組みで`).
// Deliberately NOT `#[non_exhaustive]`: serialize / render must handle every
// arm explicitly so a future layout is compiler-flagged at every site rather
// than silently dropped by a `_` fallback (the §7.6 param-drop bug class).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub enum IndentLayout {
    /// A plain `[#ここから N字下げ]` block — no secondary layout.
    None,
    /// `[#ここから N字下げ、M字詰め]` — also sets `M` full-width chars per line.
    LineWidth(LineWidth),
    /// `[#ここから N字下げ、L行W字組みで]` — sets `L` lines of `W` chars.
    Kumi(Kumi),
}

/// Co-applied decorative styles stacked on a block-layout opener.
///
/// The `[#ここから N字下げ、…]` compound lets several typographic attributes
/// ride one indent opener and close with the single generic `字下げ終わり`
/// (pairing is by family — the decorations never touch the close). This is the
/// *closed* set of those decorations.
///
/// A struct of named fields (not a bitset, not a `Vec`) is deliberate:
/// - `font` carries a magnitude no bit can hold (`小さい活字` = one step
///   smaller), and a single `Option<FontShift>` makes "two conflicting sizes"
///   and "the size flag set with no magnitude" both unrepresentable;
/// - the three `bool`s make a duplicated attribute unrepresentable;
/// - it stays `Copy` (a `Vec<Format>` would break the arena `Copy` chain).
///
/// Each field projects to the scope-independent [`Format`] identity via
/// [`Self::iter_formats`], so render / serialize / wire reuse the existing
/// per-[`Format`] machinery rather than re-deciding keyword/class/tag here.
///
/// The name is deliberately generic (not `IndentStyles`): a future block
/// anchor can carry the same decoration set without a second rearchitecture.
//
// Deliberately NOT `#[non_exhaustive]`: a fifth decoration must be
// compiler-flagged everywhere it is consumed, never silently defaulting to
// "absent" (the §7.6 param-drop bug class). The serialize and render sites
// destructure `{ gothic, horizontal, framed, font }` with no `..` directly; the
// Pandoc kvs + JSON projection funnel through [`Self::iter_formats`], which
// destructures exhaustively too — so every channel is guarded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct BlockStyles {
    /// `ゴシック体` — co-applied gothic typeface (`Format::Gothic`).
    pub gothic: bool,
    /// `横書き` / `横組み` — horizontal writing (`Format::Horizontal`).
    pub horizontal: bool,
    /// `罫囲み` — ruled box around the block (`Format::Framed`).
    pub framed: bool,
    /// `小さい活字` (= `FontShift(-1)`) / `N段階…文字` — relative font shift
    /// (`Format::FontSize`). `None` = no shift; a single `Option` forbids two
    /// conflicting sizes.
    pub font: Option<FontShift>,
}

impl BlockStyles {
    /// No decorations — the overwhelmingly common plain-indent case.
    pub const EMPTY: Self = Self {
        gothic: false,
        horizontal: false,
        framed: false,
        font: None,
    };

    /// Whether no decoration is set (a plain indent that serializes byte-exact
    /// to today's `[#ここから N字下げ]`).
    #[must_use]
    pub fn is_empty(self) -> bool {
        self == Self::EMPTY
    }

    /// Project the set to its [`Format`] identities in **canonical order**
    /// (`gothic`, `horizontal`, `framed`, `font`). Serialize, render, and the
    /// Pandoc / wire `modifiers` path all consume this one order, so the
    /// canonical emission never drifts.
    ///
    /// This is the **single chokepoint** the Pandoc kvs + JSON projection feed
    /// through, so it destructures exhaustively (no `..`): a fifth decoration is
    /// compiler-flagged here, not silently dropped from those channels —
    /// completing the §7.6 param-drop guard the per-site destructures provide
    /// for serialize / render.
    pub fn iter_formats(self) -> impl Iterator<Item = Format> {
        let Self {
            gothic,
            horizontal,
            framed,
            font,
        } = self;
        [
            gothic.then_some(Format::Gothic),
            horizontal.then_some(Format::Horizontal),
            framed.then_some(Format::Framed(EnclosureKind::Rule)),
            font.map(Format::FontSize),
        ]
        .into_iter()
        .flatten()
    }
}

/// The block-only payload of an indent region.
///
/// `wrap` / `layout` / `styles` live here rather than on the single-line
/// `Indent` so the block-only clauses cannot leak into the line scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct IndentBlock {
    /// Full-width characters the block is indented by.
    pub amount: u8,
    /// Hanging-indent continuation width: `Some(M)` for `折り返して M字下げ`.
    pub wrap: Option<u8>,
    /// `true` for the combined `…、ページの左右中央` / `…、中央揃え` form
    /// (also page-centred).
    pub center: bool,
    /// Secondary line-layout clause; see [`IndentLayout`].
    pub layout: IndentLayout,
    /// Co-applied decorative styles (`ゴシック体` / `横書き` / `罫囲み` /
    /// `小さい活字`); see [`BlockStyles`].
    pub styles: BlockStyles,
}

// ----------------------------------------------------------------------
// Format — the attribute identity (scope-independent)
// ----------------------------------------------------------------------

/// The typographic attribute, independent of the scope it applies at.
///
/// The single source of the attribute-identity tag ([`Self::as_json_tag`]).
/// Each scope sum ([`ForwardAttr`] / [`LineFormat`] / [`RegionFormat`])
/// projects to this via its `format()` method, so cross-scope grouping
/// (e.g. "is this the same attribute as that one?") keys on one enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Format {
    /// 太字 (bold).
    Bold,
    /// ゴシック体 (gothic / sans-serif). A distinct typeface family, **not** a
    /// weight of 太字: the corpus uses ゴシック体 and 太字 in disjoint works and
    /// print sets them differently, so the parser keeps them separate (#435).
    Gothic,
    /// 斜体 (italic).
    Italic,
    /// 傍点 / 傍線 (emphasis dots / sidelines).
    Bouten(BoutenKind),
    /// 罫囲み (ruled box) / 「□」囲み (box glyph) — an enclosure of some
    /// [`EnclosureKind`].
    Framed(EnclosureKind),
    /// 横組み (horizontal writing).
    Horizontal,
    /// N段階大きな / 小さな文字 (relative font size).
    FontSize(FontShift),
    /// 特大 / 大 / 中 / 小文字 (absolute font size).
    FontSizeAbsolute(AbsoluteSize),
    /// キャプション (caption).
    Caption,
    /// 上付き小文字 (superscript).
    SuperScript,
    /// 下付き小文字 (subscript).
    SubScript,
    /// 行右 / 行左小書き (small side-script).
    SmallScript(BoutenPosition),
    /// 縦中横 (tate-chu-yoko).
    CombineUpright,
    /// 分数 (fraction, `「a/b」は分数`).
    Fraction,
    /// ドット付き (#331 dotted-letter composition, `mは上ドット付き` → ṁ).
    AccentDot,
    /// アクサン / ウムラウト (forward accent mark, `「e」はアクサン(´)付き` → é).
    /// The [`AccentMark`] rides the scope sum ([`ForwardAttr::Accent`]); the
    /// attribute identity discards it, like [`Self::AccentDot`].
    Accent,
    /// 字下げ (indent).
    Indent,
    /// 地付き / 地から N 字上げ (end alignment).
    AlignEnd,
    /// 中央 (centring).
    Center,
    /// 字詰め (line width).
    LineWidth,
    /// 表 (table).
    Table,
    /// 段組 (multi-column).
    Columns(ColumnCount),
    /// 割り注 (split annotation).
    Warichu,
    /// 見出し (heading).
    Heading {
        /// The 大 / 中 / 小 outline level.
        level: HeadingKind,
        /// Standard / 同行 / 窓 style.
        style: HeadingStyle,
    },
}

impl Format {
    /// Stable camelCase attribute-identity tag (exhaustive — no `_` fallback,
    /// so a new attribute fails to build until it is given a tag).
    #[must_use]
    pub const fn as_json_tag(self) -> &'static str {
        match self {
            Self::Bold => "bold",
            Self::Gothic => "gothic",
            Self::Italic => "italic",
            Self::Bouten(_) => "bouten",
            Self::Framed(_) => "framed",
            Self::Horizontal => "horizontal",
            Self::FontSize(_) => "fontSize",
            Self::FontSizeAbsolute(_) => "fontSizeAbsolute",
            Self::Caption => "caption",
            Self::SuperScript => "superScript",
            Self::SubScript => "subScript",
            Self::SmallScript(_) => "smallScript",
            Self::CombineUpright => "combineUpright",
            Self::Fraction => "fraction",
            Self::AccentDot => "accentDot",
            Self::Accent => "accent",
            Self::Indent => "indent",
            Self::AlignEnd => "alignEnd",
            Self::Center => "center",
            Self::LineWidth => "lineWidth",
            Self::Table => "table",
            Self::Columns(_) => "columns",
            Self::Warichu => "warichu",
            Self::Heading { .. } => "heading",
        }
    }
}

// ----------------------------------------------------------------------
// Forward scope — `「X」は…` reference-attached emphasis
// ----------------------------------------------------------------------

/// The attributes legal at the forward-reference scope (`「X」は太字` etc.).
///
/// The content-carrying leaf that pairs an attribute with its target run is
/// the forward-reference leaf (`ForwardFormat`); this enum is the
/// attribute alone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub(crate) enum ForwardAttr {
    /// 太字.
    Bold,
    /// ゴシック体 — gothic typeface (distinct from 太字, see [`Format::Gothic`]).
    Gothic,
    /// 斜体.
    Italic,
    /// 上付き小文字.
    SuperScript,
    /// 下付き小文字.
    SubScript,
    /// 行右 / 行左小書き.
    SmallScript(BoutenPosition),
    /// 罫囲み / 「□」囲み — an enclosure of some [`EnclosureKind`].
    Framed(EnclosureKind),
    /// 横組み.
    Horizontal,
    /// キャプション.
    Caption,
    /// N段階大きな / 小さな文字.
    FontSize(FontShift),
    /// 特大 / 大 / 中 / 小文字 — absolute font size (`「X」は小文字`).
    FontSizeAbsolute(AbsoluteSize),
    /// 傍点 / 傍線. `position` records a `左に` left-side modifier.
    Bouten {
        /// The 傍点 / 傍線 mark.
        kind: BoutenKind,
        /// `Left` for the `左に` modifier, else `Right`.
        position: BoutenPosition,
    },
    /// 縦中横.
    CombineUpright,
    /// 分数 (`「a/b」は分数`). The render arm splits the target on `/` (ASCII)
    /// or `/` (fullwidth) into a `<sup>`/`<sub>` fraction.
    Fraction,
    /// ドット付き (#331). Composes a combining dot onto an addressed Latin letter
    /// in the reclaimed run (`Sam` + `mは上ドット付き` → `Saṁ`). The selector
    /// grammar lives in the raw directive body, interned on the owned leaf's
    /// [`ForwardPayload::AccentBody`](crate::syntax::ast::ForwardPayload::AccentBody)
    /// (this attribute stays a `Copy` unit, arena-free).
    AccentDot,
    /// アクサン / ウムラウト — map a single quoted Latin letter to its precomposed
    /// accented glyph (`「e」はアクサン(´)付き` → é, `「o」はウムラウト(¨)付き` → ö).
    /// The letter rides on the owned leaf's `target`; only the [`AccentMark`] is
    /// carried here (no interned body, unlike [`Self::AccentDot`]). Serialized
    /// separately (the suffix carries the bracketed mark symbol, not a bare
    /// keyword), so [`Self::keyword`] falls through to its 太字 default.
    Accent(AccentMark),
    /// End-relative alignment — `「X」は文末より N字上げ揃え` / `「X」は地付き` —
    /// aligns the target run to the text-end edge. `offset` 0 is flush-to-end
    /// (`地付き`); N ≥ 1 lifts the run N full-width chars off the edge. The
    /// forward-scope analogue of [`LineFormat::AlignEnd`]; like it, the input
    /// anchor (文末 / 行末 / 地より / 地から) is not distinguished, only the offset.
    /// Serialized separately (it carries a magnitude), so [`Self::keyword`]
    /// falls through to its 太字 default.
    AlignEnd {
        /// Chars lifted off the text-end edge; 0 = flush (`地付き`), N ≥ 1 = lift.
        offset: u8,
    },
}

/// A forward emphasis node's target-text provenance — whether `serialize`
/// must re-emit the leading literal to reconstruct the source.
///
/// A forward reference whose target appears *contiguously* in the source
/// before the bracket carries the literal's provenance
/// ([`Reclaimed`](Self::Reclaimed) / [`Referenced`](Self::Referenced)). A
/// directive whose quoted target is **absent** from the preceding source
/// (`(例)[#「国境が消える」に傍点]`) has no upstream copy, so the quote itself
/// is the styled run — [`SelfContained`](Self::SelfContained). Only a target
/// split by a ruby run (`牛《ベゴ》の舌[#「牛の舌」に傍点]`) stays an unresolved
/// directive, never reaching this type.
///
/// This is the one irreducible provenance the normalization waist could not
/// fold away — it cannot collapse to a constant in either direction:
/// - **Not always `Reclaimed`** (node owns the literal): when the recognized
///   target occurrence is itself a **ruby base**
///   (`我《が》…我[#「我」に傍点]`; ≥34 in the 17,889-work `aozorabunko_text`
///   mirror, see #202) it cannot be pulled into a text-only forward leaf
///   (bouten-over-ruby is not representable), so it must stay `Referenced`.
/// - **Not always `Referenced`** (literal left upstream): an adjacent forward
///   had its literal pulled into the node and the surrounding plain run
///   truncated, so without re-emit the literal would be lost.
///
/// Deriving it at serialize from a non-local preceding-content scan would
/// re-introduce exactly the lookback the scope-free core removes, so it is
/// materialized here as explicit provenance. (The #180 unbounded-growth
/// pathology — a `Reclaimed` literal doubled in the plain tail — is separately
/// cured by the lowering pass's overlap-truncate.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub(crate) enum ForwardOrigin {
    /// The classifier pulled the literal out of the immediately-preceding
    /// source (`青空[#「青空」に傍点]`): the surrounding plain run was truncated,
    /// so the decorated run is the *sole* visible copy and the serializer
    /// re-emits the literal before the bracket.
    Reclaimed,
    /// The target is recognized contiguously before the bracket but is *not*
    /// byte-adjacent to it, so the literal stays in the preceding run; the
    /// serializer emits the bracket form alone.
    Referenced,
    /// The quoted target is **absent** from the source preceding the bracket
    /// (the no-referent case: `forward_target_is_preceded` is false), so the
    /// directive's own quote is the *only* copy of the run. There is no upstream
    /// literal to truncate and no pull-back, so the consume window equals the
    /// bracket span exactly: the renderer styles the quoted target (it is **not**
    /// a no-op like [`Referenced`](Self::Referenced)) and the serializer emits
    /// the bracket form alone (no leading literal like
    /// [`Reclaimed`](Self::Reclaimed)). #228-safe by construction — with no
    /// earlier copy there is nothing to double-render. Produced by the
    /// no-referent classifier paths; everywhere else it is the natural
    /// fall-through of the guards that special-case `Reclaimed`/`Referenced`.
    SelfContained,
    /// The styled-literal half of a **non-adjacent** forward-reference split
    /// pair (`太字の[#「太字」は太字]`). The classifier located the target as an
    /// *interior* occurrence of the current plain run — present, but not
    /// byte-adjacent to the bracket — and pulled that occurrence out of the
    /// surrounding plain run into this decoration leaf, so it renders **once**,
    /// styled. The directive bracket stays a separate
    /// [`Referenced`](Self::Referenced) node (renders nothing, serializes the
    /// `[#…]` alone). This is the provenance dual of `Referenced`: the
    /// renderer styles the target (**not** a no-op), and the serializer emits
    /// the literal **alone** — no bracket, because the bracket is the separate
    /// `Referenced` node's job (unlike [`Reclaimed`](Self::Reclaimed), which
    /// re-emits literal *and* bracket from one node). #228-safe by construction:
    /// the literal was removed from the plain run, so exactly one copy exists.
    /// Produced only by the interior-referent resolver path (#333), never via
    /// [`from_consume`](Self::from_consume).
    Detached,
}

impl ForwardOrigin {
    /// Derive the provenance from the classifier's consume window:
    /// [`Reclaimed`](Self::Reclaimed) iff `consume_start` was pulled back
    /// before the directive's `[` at `bracket_start`, otherwise
    /// [`Referenced`](Self::Referenced).
    #[must_use]
    pub(crate) const fn from_consume(consume_start: u32, bracket_start: u32) -> Self {
        if consume_start < bracket_start {
            Self::Reclaimed
        } else {
            Self::Referenced
        }
    }
}

impl ForwardAttr {
    /// Project to the scope-independent [`Format`] attribute.
    #[must_use]
    #[cfg(test)]
    pub(crate) const fn format(self) -> Format {
        match self {
            Self::Bold => Format::Bold,
            Self::Gothic => Format::Gothic,
            Self::Italic => Format::Italic,
            Self::SuperScript => Format::SuperScript,
            Self::SubScript => Format::SubScript,
            Self::SmallScript(p) => Format::SmallScript(p),
            Self::Framed(k) => Format::Framed(k),
            Self::Horizontal => Format::Horizontal,
            Self::Caption => Format::Caption,
            Self::FontSize(f) => Format::FontSize(f),
            Self::FontSizeAbsolute(s) => Format::FontSizeAbsolute(s),
            Self::Bouten { kind, .. } => Format::Bouten(kind),
            Self::CombineUpright => Format::CombineUpright,
            Self::Fraction => Format::Fraction,
            Self::AccentDot => Format::AccentDot,
            Self::Accent(_) => Format::Accent,
            Self::AlignEnd { .. } => Format::AlignEnd,
        }
    }

    /// Canonical 青空文庫 keyword for the treatment (the body of
    /// `「X」は〈keyword〉` / `「X」に〈keyword〉`).
    ///
    /// [`Self::FontSize`] carries a magnitude and is serialized separately, so
    /// it falls through to the 太字 default here (the serializer never calls
    /// this for it).
    #[must_use]
    pub(crate) const fn keyword(self) -> &'static str {
        match self {
            Self::Gothic => "ゴシック体",
            Self::Italic => "斜体",
            Self::SuperScript => "上付き小文字",
            Self::SubScript => "下付き小文字",
            Self::SmallScript(BoutenPosition::Right) => "行右小書き",
            Self::SmallScript(BoutenPosition::Left) => "行左小書き",
            Self::Framed(EnclosureKind::Rule) => "罫囲み",
            // 「□」囲み: the source keyword embeds the quoted glyph, so serialize
            // reconstructs it in a dedicated arm; this bare base word only feeds
            // the keyword round-trip table.
            Self::Framed(EnclosureKind::Box) => "囲み",
            // These three carry no embedded glyph, so the keyword *is* the whole
            // `は…` suffix and serialize round-trips through the default path.
            Self::Framed(EnclosureKind::Circle) => "○付き文字",
            Self::Framed(EnclosureKind::CircleDotted) => "点線丸囲み",
            Self::Framed(EnclosureKind::DoubleRule) => "二重罫囲み",
            Self::Horizontal => "横組み",
            Self::Caption => "キャプション",
            Self::CombineUpright => "縦中横",
            Self::Fraction => "分数",
            Self::FontSizeAbsolute(s) => s.keyword(),
            Self::Bouten { kind, .. } => kind.keyword(),
            // Bold, FontSize, and any future weight default to 太字.
            _ => "太字",
        }
    }
}

// ----------------------------------------------------------------------
// Line scope — single-line layout directives
// ----------------------------------------------------------------------

/// The attributes legal at the single-line scope (`[#地付き]` etc.).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub(crate) enum LineFormat {
    /// `[#天から N字下げ]` — indent the line by `amount` full-width chars.
    ///
    /// `end_offset` carries the *both-margin* compound
    /// (`[#N字下げ、地よりM字上げで]`): a single line set with both a head
    /// indent (`amount`) and a foot-edge lift of `M` full-width chars —
    /// `Some(M)` has the same foot-edge semantics as an
    /// [`AlignEnd`](Self::AlignEnd) with `offset: M`. `None` is the plain
    /// head-only indent.
    Indent {
        /// Full-width characters to indent by.
        amount: u8,
        /// `Some(M)` lifts the line `M` full-width chars off the foot edge (the
        /// `、地よりM字上げで` clause of the both-margin compound); `None` = a
        /// plain head-only indent.
        end_offset: Option<u8>,
    },
    /// `[#地付き]` / `[#地から N字上げ]` — end alignment.
    AlignEnd {
        /// Chars lifted off the foot edge. `0` = 地付き, `n` = 地から n 字上げ.
        offset: u8,
    },
    /// `中央揃え` / `ページの左右中央` — centring.
    Center {
        /// `true` for `ページの左右中央` (page centre), `false` for `中央揃え`.
        page: bool,
    },
    /// `[#この行はゴシック体]` — set the single line it sits on in gothic
    /// ([`Format::Gothic`], distinct from 太字).
    Gothic,
    /// `[#大文字]` … `[#特大文字、太字]` — an absolute font size applied to
    /// the whole line (the postfix headline form). `bold` records a co-applied
    /// `、太字` (the `、ゴシック体` postfix is not recognised on this line form —
    /// see `parse_line_font_size`).
    FontSizeAbsolute {
        /// The absolute size.
        size: AbsoluteSize,
        /// `true` for the `、太字` compound.
        bold: bool,
    },
}

impl LineFormat {
    /// Project to the scope-independent [`Format`] attribute.
    #[must_use]
    #[cfg(test)]
    pub(crate) const fn format(self) -> Format {
        match self {
            Self::Indent { .. } => Format::Indent,
            Self::AlignEnd { .. } => Format::AlignEnd,
            Self::Center { .. } => Format::Center,
            Self::Gothic => Format::Gothic,
            Self::FontSizeAbsolute { size, .. } => Format::FontSizeAbsolute(size),
        }
    }
}

// ----------------------------------------------------------------------
// Region scope — paired range / block containers
// ----------------------------------------------------------------------

/// The attributes legal at the paired range / block scope.
///
/// The open marker of a `[#ここから…] … [#ここで…終わり]` (or bare
/// `[#…] … [#…終わり]`) pair. The matching close is a [`RegionClose`];
/// the open payload here stays authoritative when the pair round-trips.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RegionFormat {
    /// 太字 range / block. `padded` = block-level (`<div>`, `\n\n` padded);
    /// `!padded` = inline bare range (`<b>`).
    Bold {
        /// `true` = `ここから` block; `false` = inline bare range.
        padded: bool,
    },
    /// ゴシック体 range / block (`[#ここからゴシック体] …`). The gothic
    /// counterpart of [`Self::Bold`] (distinct typeface, [`Format::Gothic`]).
    Gothic {
        /// `true` = `ここから` block; `false` = inline bare range.
        padded: bool,
    },
    /// 斜体 range / block — the slant counterpart of [`Self::Bold`].
    Italic {
        /// `true` = `ここから` block; `false` = inline bare range.
        padded: bool,
    },
    /// キャプション range / block.
    Caption {
        /// `true` = `ここから` block; `false` = inline bare range.
        padded: bool,
    },
    /// 見出し (delimited heading) range / block.
    Heading {
        /// The 大 / 中 / 小 outline level.
        level: HeadingKind,
        /// Standard / 同行 / 窓 style.
        style: HeadingStyle,
        /// `true` = `ここから` block; `false` = paired `窓中見出し…終わり`.
        padded: bool,
    },
    /// 傍点 / 傍線 range (`[#傍点] … [#傍点終わり]`, `[#左に傍線] …`).
    Bouten {
        /// The 傍点 / 傍線 mark; its 点/線 family drives the mismatch check.
        kind: BoutenKind,
        /// `Left` for the `左に` modifier, else `Right`.
        position: BoutenPosition,
    },
    /// 行右 / 行左小書き range.
    SmallScript(BoutenPosition),
    /// 字下げ block (`[#ここから N字下げ] …`).
    Indent(IndentBlock),
    /// 地付き / 地から N 字上げ block.
    AlignEnd {
        /// Chars lifted off the foot edge. `0` = 地付き, `n` = 地から n 字上げ.
        offset: u8,
    },
    /// 字詰め block (`[#ここから N字詰め] …`).
    LineWidth(LineWidth),
    /// 表 block.
    Table,
    /// 段組 block.
    Columns(ColumnCount),
    /// 横組み block.
    Horizontal,
    /// N段階大きな / 小さな文字 block.
    FontSize(FontShift),
    /// 罫囲み block / range ([`EnclosureKind`]).
    Framed(EnclosureKind),
    /// 割り注 block (multi-line `[#割り注] … [#割り注終わり]`).
    Warichu,
}

impl RegionFormat {
    /// Project to the scope-independent [`Format`] attribute.
    #[must_use]
    pub const fn format(self) -> Format {
        match self {
            Self::Bold { .. } => Format::Bold,
            Self::Gothic { .. } => Format::Gothic,
            Self::Italic { .. } => Format::Italic,
            Self::Caption { .. } => Format::Caption,
            Self::Heading { level, style, .. } => Format::Heading { level, style },
            Self::Bouten { kind, .. } => Format::Bouten(kind),
            Self::SmallScript(p) => Format::SmallScript(p),
            Self::Indent(_) => Format::Indent,
            Self::AlignEnd { .. } => Format::AlignEnd,
            Self::LineWidth(_) => Format::LineWidth,
            Self::Table => Format::Table,
            Self::Columns(c) => Format::Columns(c),
            Self::Horizontal => Format::Horizontal,
            Self::FontSize(f) => Format::FontSize(f),
            Self::Framed(k) => Format::Framed(k),
            Self::Warichu => Format::Warichu,
        }
    }

    /// Stable camelCase wire tag — the machine-contract counterpart used by the
    /// `container_pairs` driver endpoint. Scope-specific (`boutenRange`,
    /// `combineUprightRange`), distinct from the attribute-level
    /// [`Format::as_json_tag`]; exhaustive so a new variant cannot fall through
    /// to a silent `"unknown"`.
    #[must_use]
    pub const fn as_json_tag(self) -> &'static str {
        match self {
            Self::Indent(_) => "indent",
            Self::Warichu => "warichu",
            Self::Framed(_) => "framed",
            Self::AlignEnd { .. } => "alignEnd",
            Self::LineWidth(_) => "lineWidth",
            Self::Bouten { .. } => "boutenRange",
            Self::Bold { .. } => "bold",
            Self::Gothic { .. } => "gothic",
            Self::Italic { .. } => "italic",
            Self::Heading { .. } => "heading",
            Self::Columns(_) => "columns",
            Self::Table => "table",
            Self::Horizontal => "horizontal",
            Self::FontSize(_) => "fontSize",
            Self::SmallScript(_) => "smallScript",
            Self::Caption { .. } => "caption",
        }
    }

    /// Stable lowercase kebab family tag for human-facing diagnostics (the
    /// mismatched open/close pair names). Payload-independent.
    #[must_use]
    pub const fn kind_str(self) -> &'static str {
        match self {
            Self::Indent(_) => "indent",
            Self::Warichu => "warichu",
            Self::Framed(_) => "framed",
            Self::AlignEnd { .. } => "align-end",
            Self::LineWidth(_) => "line-width",
            Self::Bouten { .. } => "bouten-range",
            Self::Bold { .. } => "bold",
            Self::Gothic { .. } => "gothic",
            Self::Italic { .. } => "italic",
            Self::Heading { .. } => "heading",
            Self::Columns(_) => "columns",
            Self::Table => "table",
            Self::Horizontal => "horizontal",
            Self::FontSize(_) => "font-size",
            Self::SmallScript(_) => "small-script",
            Self::Caption { .. } => "caption",
        }
    }

    /// Whether this region renders *inline* (within the current paragraph)
    /// rather than as a block wrapper.
    ///
    /// The 傍点 / 傍線 range, the bare-range 太字 / 斜体 / キャプション forms
    /// (`!padded`), the 小書き range, and the 縦中横 range sit within a line;
    /// every other region is block-level (gets `\n\n` padding + a `<div>`).
    #[must_use]
    pub const fn is_inline(self) -> bool {
        matches!(
            self,
            Self::Bouten { .. }
                | Self::Bold { padded: false }
                | Self::Gothic { padded: false }
                | Self::Italic { padded: false }
                | Self::SmallScript(_)
                | Self::Caption { padded: false }
        )
    }

    /// Whether this region's content is *phrasing* (rendered directly inside
    /// the block element rather than wrapped in `<p>` paragraphs).
    ///
    /// Only [`Self::Heading`] is phrasing: a heading element holds its title
    /// directly, so `<h1><p>…</p></h1>` would be invalid.
    #[must_use]
    pub const fn content_is_phrasing(self) -> bool {
        matches!(self, Self::Heading { .. })
    }

    /// Every variant, one representative instance per data-carrying variant —
    /// the payload is irrelevant to the discriminant-only tag projections. Lets
    /// the wire-tag exhaustiveness test and the codegen enumerate the family
    /// list without a hand-maintained parallel.
    pub const ALL: [Self; 16] = [
        Self::Indent(IndentBlock {
            amount: 0,
            wrap: None,
            center: false,
            layout: IndentLayout::None,
            styles: BlockStyles::EMPTY,
        }),
        Self::Warichu,
        Self::Framed(EnclosureKind::Rule),
        Self::AlignEnd { offset: 0 },
        Self::LineWidth(LineWidth(NonZeroU8::MIN)),
        Self::Bouten {
            kind: BoutenKind::Goma,
            position: BoutenPosition::Right,
        },
        Self::Bold { padded: false },
        Self::Gothic { padded: false },
        Self::Italic { padded: false },
        Self::Heading {
            level: HeadingKind::Large,
            style: HeadingStyle::Standard,
            padded: false,
        },
        Self::Columns(ColumnCount(NonZeroU8::MIN)),
        Self::Table,
        Self::Horizontal,
        Self::FontSize(FontShift(NonZeroI8::MIN)),
        Self::SmallScript(BoutenPosition::Right),
        Self::Caption { padded: false },
    ];
}

// ----------------------------------------------------------------------
// Region close — self-sufficient; carries the close marker's own data
// ----------------------------------------------------------------------

/// The close marker of a paired region.
///
/// Carries exactly what the close marker (and HTML close tag) reproduce from
/// the **close** source text — never a placeholder. A close can appear without
/// a matching open (a stray `[#…終わり]`) and a mismatched close keeps its
/// own family (`[#傍線終わり]` closing a `[#傍点]`), so the close must be
/// self-sufficient; it is *not* reconstructed from the open. `Option` /
/// `NonZero` / `bool` make the former `width: 0` / `steps: ±1` / `lines: 0`
/// placeholder states unrepresentable, which was Pillar 1's actual goal.
///
/// It stays meaningfully smaller than [`RegionFormat`] (the indent, line-width,
/// columns, align-end, and font-size closes all shed payload), so the separate
/// type still earns its keep.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub(crate) enum RegionClose {
    /// `字下げ終わり`, or the `字下げ、{W}字組み終わり` compound (the close
    /// carries `W`, so the marker round-trips byte-exact).
    Indent {
        /// The `W` of a `字組み終わり` compound; `None` for the generic
        /// `字下げ終わり` (plain / 字詰め / 折り返して / 中央 indents).
        kumi_width: Option<LineWidth>,
    },
    /// `割り注終わり`.
    Warichu,
    /// `罫囲み終わり` ([`EnclosureKind`]).
    Framed(EnclosureKind),
    /// `字上げ終わり` / 地付き close (no offset — the close marker carries none).
    AlignEnd,
    /// `字詰め終わり`.
    LineWidth,
    /// `傍点終わり` / `傍線終わり` / `波線終わり` … — the close's own mark and
    /// `左に` side. The 点/線 family (`kind.is_line()`) drives the
    /// `mismatched_bouten_container` diagnostic.
    Bouten {
        /// The close marker's own 傍点 / 傍線 mark.
        kind: BoutenKind,
        /// `Left` for the close's `左に` modifier, else `Right`.
        position: BoutenPosition,
    },
    /// `太字終わり` (`!padded`) / `ここで太字終わり` (`padded`).
    Bold {
        /// `true` = `ここで` block close; `false` = inline bare-range close.
        padded: bool,
    },
    /// `ゴシック体終わり` (`!padded`) / `ここでゴシック体終わり` (`padded`).
    Gothic {
        /// `true` = `ここで` block close; `false` = inline bare-range close.
        padded: bool,
    },
    /// `斜体終わり` / `ここで斜体終わり`.
    Italic {
        /// `true` = `ここで` block close; `false` = inline bare-range close.
        padded: bool,
    },
    /// `<見出し>終わり` / `ここで<見出し>終わり`.
    Heading {
        /// The 大 / 中 / 小 outline level. `None` for the level-less bare
        /// `ここで見出し終わり` close (the open payload stays authoritative for
        /// pairing/render; this only round-trips the close marker's own
        /// spelling); `Some(k)` for a leveled close.
        level: Option<HeadingKind>,
        /// Standard / 同行 / 窓 style.
        style: HeadingStyle,
        /// `true` = `ここで` block close; `false` = paired `…終わり` close.
        padded: bool,
    },
    /// `段組終わり`.
    Columns,
    /// `表終わり`.
    Table,
    /// `横組み終わり`.
    Horizontal,
    /// `大きな文字終わり` (`larger`) / `小さな文字終わり`.
    FontSize {
        /// `true` = 大きな (larger); `false` = 小さな (smaller).
        larger: bool,
    },
    /// `行右 / 行左小書き終わり`.
    SmallScript(BoutenPosition),
    /// `キャプション終わり` (`!padded`) / `ここでキャプション終わり` (`padded`).
    Caption {
        /// `true` = `ここで` block close; `false` = inline bare-range close.
        padded: bool,
    },
}

impl RegionClose {
    /// The close that matches a given open [`RegionFormat`], preserving the
    /// open's payload.
    ///
    /// Used two ways: in the classifier, on the [`RegionFormat`] parsed from
    /// the *close* marker (so it carries the close's own data); and in the
    /// pairing-mismatch check, on the *open* (to derive the expected close).
    #[must_use]
    pub(crate) const fn of(region: RegionFormat) -> Self {
        match region {
            RegionFormat::Indent(block) => Self::Indent {
                kumi_width: match block.layout {
                    IndentLayout::Kumi(kumi) => Some(LineWidth(kumi.width)),
                    IndentLayout::LineWidth(_) | IndentLayout::None => None,
                },
            },
            RegionFormat::Warichu => Self::Warichu,
            RegionFormat::Framed(k) => Self::Framed(k),
            RegionFormat::AlignEnd { .. } => Self::AlignEnd,
            RegionFormat::LineWidth(_) => Self::LineWidth,
            RegionFormat::Bouten { kind, position } => Self::Bouten { kind, position },
            RegionFormat::Bold { padded } => Self::Bold { padded },
            RegionFormat::Gothic { padded } => Self::Gothic { padded },
            RegionFormat::Italic { padded } => Self::Italic { padded },
            RegionFormat::Heading {
                level,
                style,
                padded,
            } => Self::Heading {
                // `of` always yields a leveled close (a `None` close originates
                // only from the classifier parsing a bare `見出し終わり` marker).
                level: Some(level),
                style,
                padded,
            },
            RegionFormat::Columns(_) => Self::Columns,
            RegionFormat::Table => Self::Table,
            RegionFormat::Horizontal => Self::Horizontal,
            RegionFormat::FontSize(shift) => Self::FontSize {
                larger: shift.larger(),
            },
            RegionFormat::SmallScript(side) => Self::SmallScript(side),
            RegionFormat::Caption { padded } => Self::Caption { padded },
        }
    }

    /// Stable lowercase kebab family tag for human-facing diagnostics (mirrors
    /// [`RegionFormat::kind_str`] so an open/close mismatch names both sides
    /// consistently).
    #[must_use]
    pub(crate) const fn kind_str(self) -> &'static str {
        match self {
            Self::Indent { .. } => "indent",
            Self::Warichu => "warichu",
            Self::Framed(_) => "framed",
            Self::AlignEnd => "align-end",
            Self::LineWidth => "line-width",
            Self::Bouten { .. } => "bouten-range",
            Self::Bold { .. } => "bold",
            Self::Gothic { .. } => "gothic",
            Self::Italic { .. } => "italic",
            Self::Heading { .. } => "heading",
            Self::Columns => "columns",
            Self::Table => "table",
            Self::Horizontal => "horizontal",
            Self::FontSize { .. } => "font-size",
            Self::SmallScript(_) => "small-script",
            Self::Caption { .. } => "caption",
        }
    }

    /// Whether the close renders inline (mirrors [`RegionFormat::is_inline`] so
    /// the close marker's `\n\n` padding / `<p>` handling matches the open).
    #[must_use]
    pub(crate) const fn is_inline(self) -> bool {
        matches!(
            self,
            Self::Bouten { .. }
                | Self::Bold { padded: false }
                | Self::Gothic { padded: false }
                | Self::Italic { padded: false }
                | Self::SmallScript(_)
                | Self::Caption { padded: false }
        )
    }

    /// Whether the close's content was *phrasing* — only [`Self::Heading`].
    #[must_use]
    #[cfg(test)]
    pub(crate) const fn content_is_phrasing(self) -> bool {
        matches!(self, Self::Heading { .. })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `RegionFormat` stays small and `Copy` — pinned so a payload that bloats
    /// the registry's per-node footprint trips here.
    #[test]
    fn region_format_is_copy_and_small() {
        const fn assert_copy<T: Copy>() {}
        assert_copy::<RegionFormat>();
        assert_copy::<Format>();
        assert_copy::<ForwardAttr>();
        assert_copy::<LineFormat>();
        assert_copy::<RegionClose>();
        assert!(size_of::<RegionFormat>() <= 12);
    }

    /// The container-pairs wire tags are pinned so a rename requires a schema
    /// bump.
    #[test]
    fn region_format_wire_tags_are_stable() {
        assert_eq!(
            RegionFormat::Bouten {
                kind: BoutenKind::Goma,
                position: BoutenPosition::Right,
            }
            .as_json_tag(),
            "boutenRange"
        );
        assert_eq!(
            RegionFormat::LineWidth(LineWidth(NonZeroU8::MIN)).as_json_tag(),
            "lineWidth"
        );
        assert_eq!(RegionFormat::Bold { padded: true }.as_json_tag(), "bold");
        assert_eq!(RegionFormat::Bold { padded: false }.as_json_tag(), "bold");
        assert_eq!(
            RegionFormat::Gothic { padded: true }.as_json_tag(),
            "gothic"
        );
    }

    /// Every open round-trips to a close discriminant and back to the same
    /// family kebab tag (the open/close diagnostic vocabulary agrees).
    #[test]
    fn region_close_of_round_trips_kind_str() {
        for open in RegionFormat::ALL {
            assert_eq!(
                RegionClose::of(open).kind_str(),
                open.kind_str(),
                "close family must mirror open family for {open:?}"
            );
        }
    }

    /// The 傍点/傍線 kind + position survive the open → close projection (so a
    /// mismatched close keeps its own family for round-trip and diagnostics).
    #[test]
    fn region_close_preserves_bouten_kind() {
        let line = RegionFormat::Bouten {
            kind: BoutenKind::UnderLine,
            position: BoutenPosition::Left,
        };
        assert_eq!(
            RegionClose::of(line),
            RegionClose::Bouten {
                kind: BoutenKind::UnderLine,
                position: BoutenPosition::Left,
            }
        );
    }

    /// The 字組み close keeps its own width; every other indent close is generic.
    #[test]
    fn region_close_indent_keeps_kumi_width() {
        let kumi = RegionFormat::Indent(IndentBlock {
            amount: 2,
            wrap: None,
            center: false,
            layout: IndentLayout::Kumi(Kumi {
                lines: NonZeroU8::MIN,
                width: NonZeroU8::new(20).unwrap(),
            }),
            styles: BlockStyles::EMPTY,
        });
        assert_eq!(
            RegionClose::of(kumi),
            RegionClose::Indent {
                kumi_width: Some(LineWidth(NonZeroU8::new(20).unwrap())),
            }
        );
        let plain = RegionFormat::Indent(IndentBlock {
            amount: 2,
            wrap: None,
            center: false,
            layout: IndentLayout::None,
            styles: BlockStyles::EMPTY,
        });
        assert_eq!(
            RegionClose::of(plain),
            RegionClose::Indent { kumi_width: None }
        );
    }

    /// Attribute-level tags are exhaustive and distinct from the scope wire
    /// tags (e.g. `bouten` vs `boutenRange`).
    #[test]
    fn format_attribute_tags() {
        assert_eq!(Format::Bouten(BoutenKind::Goma).as_json_tag(), "bouten");
        assert_eq!(Format::CombineUpright.as_json_tag(), "combineUpright");
        assert_eq!(Format::Warichu.as_json_tag(), "warichu");
        assert_eq!(Format::Center.as_json_tag(), "center");
    }

    #[test]
    fn font_shift_larger_and_magnitude() {
        let up = FontShift(NonZeroI8::new(3).unwrap());
        let down = FontShift(NonZeroI8::new(-2).unwrap());
        // `larger`: sign of the shift. A single-step 大きな / 小さな pins both
        // the `-> true` / `-> false` stubs and the `>`→`<`/`==` operator swaps.
        assert!(up.larger(), "a positive shift enlarges");
        assert!(!down.larger(), "a negative shift shrinks");
        assert!(
            FontShift(NonZeroI8::new(1).unwrap()).larger(),
            "the smallest positive step still enlarges"
        );
        assert!(
            !FontShift(NonZeroI8::new(-1).unwrap()).larger(),
            "the smallest negative step still shrinks"
        );
        // `magnitude`: unsigned stage count, sign-independent.
        assert_eq!(up.magnitude(), 3);
        assert_eq!(down.magnitude(), 2, "magnitude drops the sign");
        assert_eq!(FontShift(NonZeroI8::new(1).unwrap()).magnitude(), 1);
    }

    #[test]
    fn block_styles_is_empty_and_iter_formats() {
        // `is_empty` is true only for the all-default set; a single flag flips
        // it (pins the `-> true`/`-> false` stubs and the `==`→`!=` swap).
        assert!(BlockStyles::EMPTY.is_empty());
        assert!(BlockStyles::default().is_empty());
        let decorated = BlockStyles {
            gothic: true,
            horizontal: false,
            framed: true,
            font: Some(FontShift(NonZeroI8::new(-1).unwrap())),
        };
        assert!(!decorated.is_empty());
        // `iter_formats` yields the set members in canonical order
        // (gothic, horizontal, framed, font) — not an empty iterator.
        assert_eq!(
            decorated.iter_formats().collect::<Vec<_>>(),
            vec![
                Format::Gothic,
                Format::Framed(EnclosureKind::Rule),
                Format::FontSize(FontShift(NonZeroI8::new(-1).unwrap())),
            ],
            "iter_formats must project set members in canonical order"
        );
        // The empty set yields nothing (but the method is not the constant
        // empty-iterator: the decorated case above proves it emits).
        assert_eq!(BlockStyles::EMPTY.iter_formats().count(), 0);
    }

    #[test]
    fn forward_origin_from_consume_pins_the_pullback_boundary() {
        // Reclaimed iff the consume window was pulled back *before* the bracket;
        // equal offsets (no pull-back) are Referenced — the exact `<` boundary.
        assert_eq!(ForwardOrigin::from_consume(5, 10), ForwardOrigin::Reclaimed);
        assert_eq!(
            ForwardOrigin::from_consume(10, 10),
            ForwardOrigin::Referenced,
            "equal offsets mean no pull-back → Referenced"
        );
        assert_eq!(
            ForwardOrigin::from_consume(15, 10),
            ForwardOrigin::Referenced
        );
    }

    #[test]
    fn region_inline_and_phrasing_predicates() {
        // `is_inline`: the 傍点 range and the bare (`!padded`) 太字 / 斜体 /
        // キャプション forms, 小書き, and 縦中横 are inline; blocks are not.
        assert!(
            RegionFormat::Bouten {
                kind: BoutenKind::Goma,
                position: BoutenPosition::Right,
            }
            .is_inline()
        );
        assert!(RegionFormat::Bold { padded: false }.is_inline());
        assert!(RegionFormat::SmallScript(BoutenPosition::Right).is_inline());
        assert!(!RegionFormat::Bold { padded: true }.is_inline());
        assert!(!RegionFormat::Table.is_inline());
        assert!(!RegionFormat::Warichu.is_inline());
        // `content_is_phrasing`: only 見出し.
        assert!(
            RegionFormat::Heading {
                level: HeadingKind::Large,
                style: HeadingStyle::Standard,
                padded: true,
            }
            .content_is_phrasing()
        );
        assert!(!RegionFormat::Table.content_is_phrasing());
        assert!(!RegionFormat::Bold { padded: false }.content_is_phrasing());
    }

    #[test]
    fn region_close_inline_and_phrasing_predicates() {
        // The close side mirrors the open side (so `\n\n` padding / `<p>`
        // handling agree across the pair).
        assert!(
            RegionClose::Bouten {
                kind: BoutenKind::Goma,
                position: BoutenPosition::Right,
            }
            .is_inline()
        );
        assert!(RegionClose::Bold { padded: false }.is_inline());
        assert!(RegionClose::SmallScript(BoutenPosition::Right).is_inline());
        assert!(!RegionClose::Bold { padded: true }.is_inline());
        assert!(!RegionClose::Table.is_inline());
        assert!(!RegionClose::Warichu.is_inline());
        assert!(
            RegionClose::Heading {
                level: Some(HeadingKind::Large),
                style: HeadingStyle::Standard,
                padded: true,
            }
            .content_is_phrasing()
        );
        assert!(!RegionClose::Table.content_is_phrasing());
        assert!(!RegionClose::Bold { padded: false }.content_is_phrasing());
    }

    /// Each scope sum projects onto a `Format` attribute (no panics / total).
    #[test]
    fn scope_projections_are_total() {
        assert_eq!(ForwardAttr::Bold.format(), Format::Bold);
        assert_eq!(LineFormat::Gothic.format(), Format::Gothic);
        assert_eq!(
            RegionFormat::Framed(EnclosureKind::Rule).format(),
            Format::Framed(EnclosureKind::Rule),
            "the enclosure identity is reached via the live block / forward scopes"
        );
        assert_eq!(RegionFormat::Warichu.format(), Format::Warichu);
        assert_eq!(
            RegionFormat::Bold { padded: true }.format(),
            Format::Bold,
            "range and forward Bold share the attribute identity"
        );
    }
}