zenith-scene 0.0.0

Zenith backend-neutral scene IR and compilation (geometry, text wrap, anchors, opacity, clip).
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
//! Threaded text flow ("text chain") pre-pass.
//!
//! A *chain* is the set of `text` nodes that share the same `chain` id. A long
//! article placed in the FIRST member (source order) flows across every
//! member's box in order: each box consumes as much text as fits, and the
//! remainder continues in the next box. This enables tri-fold leaflet panels
//! where one article spans three text boxes.
//!
//! This module runs ONCE per document (across ALL pages), BEFORE the main
//! compile walk, producing a single [`ChainAssignments`] map keyed by global
//! node id. A chain may span boxes on DIFFERENT pages: members are collected in
//! (page-order, then source-order) and the source content is poured greedily
//! across every member — box 1 fills, the remainder flows to box 2, … across
//! page boundaries. [`super::text::compile_text`] consults that map: a chain
//! member renders its ASSIGNED lines (via the shared [`super::text::emit_lines`])
//! instead of wrapping its own spans; a non-chain node is wholly unaffected
//! (byte-identical). The same document-wide map is threaded into every
//! `compile_page` call so a node on page 3 renders the slice it was assigned.
//!
//! ## v0 design choices (documented)
//!
//! - **Content source.** The chain's content is the spans of the FIRST member
//!   (source order) that has non-empty spans. Later members are continuation
//!   slots and declare `chain=id` with empty spans. If more than one member
//!   carries spans, only the first member's spans are used — spans are NOT
//!   concatenated (kept simple for v0).
//! - **Shared style.** All members are assumed to share font family/size/
//!   weight/fill. The whole chain is shaped ONCE with the first member's
//!   resolved style (+ per-span overrides). Each box re-wraps to its OWN width,
//!   so line height is uniform across the chain even when boxes differ in width.
//! - **Geometry source.** A chain member must carry explicit `x`/`y`/`w`/`h`
//!   geometry resolvable to pixels. The pre-pass runs before the flow-layout
//!   geometry injection in [`super::container`], so combining `layout="flow"`
//!   box injection WITH `chain` is a documented follow-up — a flow-injected
//!   member has no explicit box at pre-pass time and is skipped from the chain.
//! - **Opacity cascade.** The pre-pass shapes colors at opacity 1.0 (no group/
//!   frame opacity cascade), so placing chain members under an opacity-cascading
//!   group is a documented follow-up.
//!
//! ## Determinism
//!
//! Members are collected in document source order (a depth-first walk, frames
//! and groups included). The result is a [`BTreeMap`] keyed by node id, and the
//! shaping reuses the deterministic engine. No `HashMap`/time/random reaches
//! output.

use std::collections::BTreeMap;

use zenith_core::{
    Diagnostic, FontProvider, FontStyle, Node, PropertyValue, ResolvedToken, Style, TextNode,
};
use zenith_layout::{RustybuzzEngine, TextDirection};

use crate::ir::Color;

use super::markdown_resolve::MdBlockMap;
use super::paint::resolve_property_color;
use super::style_prop;
use super::text::{
    BlockStyleEnv, ChainSourceShape, HyphenationContext, LINK_COLOR, Line, LineDecoration,
    LineStyle, NodeShape, ResolvedSpan, ShapeEnv, WordMetrics, WordToken, en_us_hyphenator,
    flatten_lines_to_tokens, pack_lines, resolve_family_with_fallback, resolve_font_family_name,
    resolve_font_weight, resolve_vertical_align, shape_source_blocks, shape_words,
};
use super::util::{resolve_geometry_px, resolve_property_dimension_px};

/// The lines a single chain member must render, already shaped + packed to that
/// member's box width, plus the shared font metrics for baseline stacking.
pub(crate) struct ChainAssignment {
    pub(super) lines: Vec<Line>,
    pub(super) metrics: WordMetrics,
    /// `true` only for the LAST member of the chain (document-wide). Drives the
    /// justify last-line policy: the final member leaves its last line ragged;
    /// a continuation member justifies its last line (the paragraph flows on).
    pub(super) is_last_member: bool,
}

/// Map from node id → its assigned chain lines. Empty when the page has no
/// chains. A node whose id is absent is NOT a chain member.
pub(crate) type ChainAssignments = BTreeMap<String, ChainAssignment>;

/// A collected chain member: its node id and the box width/height (px) used to
/// distribute lines. The member's actual draw geometry (x/y/align) is resolved
/// independently inside `compile_text` from the node's own AST, so only the box
/// extents needed for distribution are carried here.
struct Member {
    id: String,
    w: f64,
    h: f64,
}

/// Resolve a text node's explicit box to pixels, or `None` if any of
/// `x`/`y`/`w`/`h` is absent, a non-dimension, an unresolved token, or uses an
/// unsupported unit. Raw `(px)` dims are byte-identical to the prior read;
/// dimension token refs resolve via the token table.
fn member_box(
    text: &TextNode,
    resolved: &BTreeMap<String, ResolvedToken>,
) -> Option<(f64, f64, f64, f64)> {
    Some((
        resolve_geometry_px(text.x.as_ref(), resolved)?,
        resolve_geometry_px(text.y.as_ref(), resolved)?,
        resolve_geometry_px(text.w.as_ref(), resolved)?,
        resolve_geometry_px(text.h.as_ref(), resolved)?,
    ))
}

/// Depth-first walk in source order collecting `(chain_id → ordered members)`
/// plus the first span-bearing member node per chain (the content source) and the
/// block-style cascade scope (the page the source lives on) for that source.
fn collect_chains<'a>(
    nodes: &'a [Node],
    page_block_styles: &'a [zenith_core::BlockStyle],
    resolved: &BTreeMap<String, ResolvedToken>,
    members: &mut BTreeMap<String, Vec<Member>>,
    source: &mut BTreeMap<String, &'a TextNode>,
    source_page_styles: &mut BTreeMap<String, &'a [zenith_core::BlockStyle]>,
) {
    for node in nodes {
        match node {
            Node::Text(t) => {
                if let Some(chain_id) = &t.chain {
                    // First span-bearing member becomes the content source. Record
                    // the page-scope block styles for that source's page, so a
                    // markdown chain resolves its block cascade against the page it
                    // is authored on (chains span pages, but the source is on one).
                    let has_spans = t.spans.iter().any(|s| !s.text.is_empty());
                    if has_spans && !source.contains_key(chain_id) {
                        source.insert(chain_id.clone(), t);
                        source_page_styles.insert(chain_id.clone(), page_block_styles);
                    }
                    if let Some((_x, _y, w, h)) = member_box(t, resolved) {
                        members.entry(chain_id.clone()).or_default().push(Member {
                            id: t.id.clone(),
                            w,
                            h,
                        });
                    }
                }
            }
            Node::Frame(f) => collect_chains(
                &f.children,
                page_block_styles,
                resolved,
                members,
                source,
                source_page_styles,
            ),
            Node::Group(g) => collect_chains(
                &g.children,
                page_block_styles,
                resolved,
                members,
                source,
                source_page_styles,
            ),
            Node::Table(t) => {
                for row in &t.rows {
                    for cell in &row.cells {
                        collect_chains(
                            &cell.children,
                            page_block_styles,
                            resolved,
                            members,
                            source,
                            source_page_styles,
                        );
                    }
                }
            }
            Node::Rect(_)
            | Node::Ellipse(_)
            | Node::Line(_)
            | Node::Code(_)
            | Node::Image(_)
            | Node::Polygon(_)
            | Node::Polyline(_)
            | Node::Instance(_)
            | Node::Field(_)
            | Node::Footnote(_)
            | Node::Toc(_)
            | Node::Shape(_)
            | Node::Connector(_)
            | Node::Pattern(_)
            | Node::Chart(_)
            | Node::Unknown(_) => {}
        }
    }
}

/// Resolve only the chain source's shared base render style: `families`,
/// `font_size`, and the node base `weight`. Does NOT build [`ResolvedSpan`]s, so
/// it is cheap enough to call on the block path where the per-span resolution
/// would allocate a [`Vec<ResolvedSpan>`] that is immediately discarded.
fn resolve_chain_base_style(
    source: &TextNode,
    resolved: &BTreeMap<String, ResolvedToken>,
    style_map: &BTreeMap<&str, &Style>,
    fonts: &dyn FontProvider,
    diagnostics: &mut Vec<Diagnostic>,
) -> (Vec<String>, f32, u16) {
    let font_family_prop = source
        .font_family
        .as_ref()
        .or_else(|| style_prop(&source.style, style_map, "font-family"));
    let raw_family_name = resolve_font_family_name(font_family_prop, resolved, "Noto Sans");
    let (family_name, fell_back, is_local) =
        resolve_family_with_fallback(fonts, &raw_family_name, "Noto Sans", 400, FontStyle::Normal);
    if fell_back {
        diagnostics.push(Diagnostic::advisory(
            "font.unresolved",
            format!(
                "text node '{}': font family '{}' not available, falling back to 'Noto Sans'",
                source.id, raw_family_name
            ),
            source.source_span,
            Some(source.id.clone()),
        ));
    }
    if is_local {
        diagnostics.push(Diagnostic::advisory(
            "font.local",
            format!(
                "text node '{}': font family '{}' resolved from a local/system font; rendering is \
                 NOT guaranteed deterministic across machines — bundle the font or guarantee the \
                 target OS provides it",
                source.id, raw_family_name
            ),
            source.source_span,
            Some(source.id.clone()),
        ));
    }
    let families = vec![family_name];

    let font_size_prop = source
        .font_size
        .clone()
        .or_else(|| style_prop(&source.style, style_map, "font-size").cloned());
    let font_size: f32 =
        resolve_property_dimension_px(font_size_prop.as_ref(), resolved, 16.0) as f32;

    let node_weight_prop: Option<&PropertyValue> = source
        .font_weight
        .as_ref()
        .or_else(|| style_prop(&source.style, style_map, "font-weight"));
    let base_weight = resolve_font_weight(node_weight_prop, resolved, 400);

    (families, font_size, base_weight)
}

/// Resolve the chain source's shared render style into `families`, `font_size`,
/// the node base weight, and the per-span [`ResolvedSpan`] carriers used for
/// shaping. Mirrors `compile_text`'s resolution at opacity 1.0 (v0: no cascade).
fn resolve_chain_style(
    source: &TextNode,
    resolved: &BTreeMap<String, ResolvedToken>,
    style_map: &BTreeMap<&str, &Style>,
    fonts: &dyn FontProvider,
    diagnostics: &mut Vec<Diagnostic>,
) -> (Vec<String>, f32, u16, Vec<ResolvedSpan>) {
    let (families, font_size, base_weight) =
        resolve_chain_base_style(source, resolved, style_map, fonts, diagnostics);

    // Node-level fill/weight fallbacks (span override → node → style → default).
    let node_fill_prop: Option<&PropertyValue> = source
        .fill
        .as_ref()
        .or_else(|| style_prop(&source.style, style_map, "fill"));
    let node_weight_prop: Option<&PropertyValue> = source
        .font_weight
        .as_ref()
        .or_else(|| style_prop(&source.style, style_map, "font-weight"));

    let mut spans: Vec<ResolvedSpan> = Vec::new();
    for span in &source.spans {
        if span.text.is_empty() {
            continue;
        }
        // Per-span fill precedence: span-level `fill` > `link` color > inherited
        // node fill > black. A link's conventional color overrides an inherited
        // node fill but not a fill set directly on the span. Non-link spans keep
        // the prior `span.fill else node.fill else black` resolution (byte-identical).
        let is_link = span.link.is_some();
        let color = span
            .fill
            .as_ref()
            .and_then(|fp| resolve_property_color(fp, resolved, diagnostics, &source.id))
            .or(is_link.then_some(LINK_COLOR))
            .or_else(|| {
                node_fill_prop
                    .and_then(|fp| resolve_property_color(fp, resolved, diagnostics, &source.id))
            })
            .unwrap_or(Color::srgb(0, 0, 0, 255));
        // Per-span highlight background color (token ref or raw color string).
        // Absent → `None` (no highlight, byte-identical to a span without it).
        let highlight: Option<Color> = span
            .highlight
            .as_ref()
            .and_then(|hp| resolve_property_color(hp, resolved, diagnostics, &source.id));
        // `code` span: bool flag that drives mono-family shaping + bg rect.
        let code = span.code == Some(true);
        // `link` span: URL retained for future annotation use.
        let link = span.link.clone();
        let weight_prop = span.font_weight.as_ref().or(node_weight_prop);
        let weight = resolve_font_weight(weight_prop, resolved, 400);
        let style = if span.italic == Some(true) {
            FontStyle::Italic
        } else {
            FontStyle::Normal
        };
        // Super/subscript: reduced size + baseline shift, shared with the
        // single-box wrap path so a chained article honors vertical-align too.
        let (span_font_size, baseline_dy) =
            resolve_vertical_align(span.vertical_align.as_deref(), font_size);
        spans.push(ResolvedSpan {
            text: span.text.clone(),
            color,
            // `link` spans are underlined by default; explicit underline OR-ed in.
            underline: span.underline == Some(true) || is_link,
            strikethrough: span.strikethrough == Some(true),
            highlight,
            code,
            link,
            weight,
            style,
            font_size: span_font_size,
            baseline_dy,
        });
    }

    (families, font_size, base_weight, spans)
}

/// Build the DOCUMENT-WIDE chain-assignment map across every page.
///
/// Chains thread across boxes on DIFFERENT pages: members are collected in
/// (page-order, then source-order) over `doc.body.pages`, each carrying its OWN
/// page's box geometry, and a chain's source content is poured greedily across
/// all members in that global order — box 1 fills, the remainder flows into
/// box 2, … across page boundaries. The returned map is keyed by global node id,
/// so `compile_page` for any page looks up the slice assigned to a box on that
/// page.
///
/// Returns an empty map when no `chain` members are present, in which case
/// `compile_text` behaves exactly as before for every node.
pub(super) fn resolve_chains_document<'a>(
    doc: &'a zenith_core::Document,
    resolved: &BTreeMap<String, ResolvedToken>,
    style_map: &BTreeMap<&str, &Style>,
    fonts: &dyn FontProvider,
    engine: &RustybuzzEngine,
    md_blocks: &MdBlockMap,
    diagnostics: &mut Vec<Diagnostic>,
) -> ChainAssignments {
    // Collect members + content sources across ALL pages in page-then-source
    // order. A `BTreeMap` per-chain member list preserves the push order, which
    // is exactly the document-wide flow order.
    let mut members: BTreeMap<String, Vec<Member>> = BTreeMap::new();
    let mut source: BTreeMap<String, &'a TextNode> = BTreeMap::new();
    let mut source_page_styles: BTreeMap<String, &'a [zenith_core::BlockStyle]> = BTreeMap::new();
    for page in &doc.body.pages {
        collect_chains(
            &page.children,
            &page.block_styles,
            resolved,
            &mut members,
            &mut source,
            &mut source_page_styles,
        );
    }

    distribute_chains(
        &members,
        &source,
        &source_page_styles,
        ChainDocStyles {
            resolved,
            style_map,
            doc_block_styles: &doc.body.block_styles,
            md_blocks,
        },
        fonts,
        engine,
        diagnostics,
    )
}

/// The document-wide style lookups threaded into [`distribute_chains`], bundled
/// so the distributor edge stays under the argument lint. `md_blocks` is the
/// parsed-markdown side-channel keyed by node id: a chain whose source id is
/// present here flows as BLOCKS; every other chain stays on the inline path.
#[derive(Clone, Copy)]
struct ChainDocStyles<'a> {
    resolved: &'a BTreeMap<String, ResolvedToken>,
    style_map: &'a BTreeMap<&'a str, &'a Style>,
    doc_block_styles: &'a [zenith_core::BlockStyle],
    md_blocks: &'a MdBlockMap,
}

/// Shared distributor: shape each chain's source once and pour its words greedily
/// across the chain's ordered members. Used by [`resolve_chains_document`]; kept
/// scope-agnostic so the collection scope (one page vs. the whole document) is
/// the ONLY thing that differs between call sites.
fn distribute_chains(
    members: &BTreeMap<String, Vec<Member>>,
    source: &BTreeMap<String, &TextNode>,
    source_page_styles: &BTreeMap<String, &[zenith_core::BlockStyle]>,
    doc_styles: ChainDocStyles,
    fonts: &dyn FontProvider,
    engine: &RustybuzzEngine,
    diagnostics: &mut Vec<Diagnostic>,
) -> ChainAssignments {
    let resolved = doc_styles.resolved;
    let style_map = doc_styles.style_map;
    let mut assignments: ChainAssignments = BTreeMap::new();

    for (chain_id, chain_members) in members {
        // A chain with no span-bearing source emits nothing.
        let Some(src) = source.get(chain_id) else {
            continue;
        };

        // Source writing direction drives RTL shaping for the whole chain (the
        // per-member emit re-reads each member's own direction for line layout).
        let direction = match src.direction.as_deref() {
            Some("rtl") => TextDirection::Rtl,
            _ => TextDirection::Ltr,
        };

        // ── BLOCK PATH ────────────────────────────────────────────────────
        // When the source id is in the parsed-markdown side-channel, this chain
        // flows as BLOCKS (headings styled, paragraphs spaced) across members.
        // Every other chain (and a markdown source that parsed to no blocks)
        // takes the historical inline path below — byte-identical.
        if let Some(blocks) = doc_styles.md_blocks.get(&src.id)
            && !blocks.is_empty()
        {
            distribute_block_chain(
                BlockChainInput {
                    src,
                    blocks: blocks.as_slice(),
                    chain_members: chain_members.as_slice(),
                    page_block_styles: source_page_styles.get(chain_id).copied().unwrap_or(&[]),
                    doc_styles,
                    direction,
                    fonts,
                    engine,
                },
                diagnostics,
                &mut assignments,
            );
            continue;
        }

        // Shape the source spans ONCE into word tokens with the shared style.
        let (families, font_size, base_weight, spans) =
            resolve_chain_style(src, resolved, style_map, fonts, diagnostics);
        let (tokens, metrics) = shape_words(
            &spans,
            &families,
            NodeShape {
                font_size,
                base_weight,
                direction,
            },
            ShapeEnv { engine, fonts },
            diagnostics,
            &src.id,
            src.source_span,
        );

        // Opt-in hyphenation for the whole chain, read from the source node.
        // Absent → `None` → packing + flattening are byte-identical to before.
        let hyph_ctx = if src.hyphenate == Some(true) {
            en_us_hyphenator().map(|dict| HyphenationContext {
                dict: Some(dict),
                engine,
                fonts,
                families: &families,
                hyphen: "-",
                direction,
                // Chain-member break-word is a documented v0 follow-up (like the
                // chain drop-cap/runaround deferrals); the chain path keeps the
                // existing hyphenation-only behavior, byte-identical to before.
                break_word: false,
            })
        } else {
            None
        };

        // Widow/orphan minimum, read from the chain source node. `None` or a
        // value < 2 leaves the greedy height-cut unadjusted (byte-identical).
        let widow_orphan = src.widow_orphan.filter(|&n| n >= 2);

        // Distribute tokens across the members' boxes in order.
        let mut remaining = tokens;
        let last_member = chain_members.len().saturating_sub(1);
        for (mi, member) in chain_members.iter().enumerate() {
            // Greedy-wrap the REMAINING words to THIS box's width.
            let mut lines = pack_lines(
                remaining,
                member.w,
                metrics.space_advance,
                hyph_ctx.as_ref(),
                metrics.line_height,
            );

            if mi == last_member {
                // Last box: keep everything that remains (it may overflow; the
                // member's own overflow handling rides in compile_text). The
                // `remaining` queue is not read again after this iteration.
                assignments.insert(
                    member.id.clone(),
                    ChainAssignment {
                        lines,
                        metrics,
                        is_last_member: true,
                    },
                );
                break;
            }

            // How many leading lines fit this box height: include lines while
            // their cumulative `height_px` does not exceed `member.h`. When all
            // heights are the uniform `metrics.line_height` this is identical to
            // `floor(member.h / line_height)` (the previous formula) — both count
            // the same number of lines at every boundary. A zero-height box yields
            // 0 so content cascades into the next box, matching the prior guard.
            let max_lines = {
                let mut cum = 0.0_f64;
                let mut count = 0usize;
                for l in &lines {
                    cum += l.height_px;
                    if cum > member.h {
                        break;
                    }
                    count += 1;
                }
                count
            };
            let mut take = max_lines.min(lines.len());

            // Widow/orphan adjustment: if the greedy cut splits a paragraph
            // across this boundary, pull lines DOWN into the next box so neither
            // side is left with fewer than N lines of that paragraph.
            if let Some(n) = widow_orphan {
                take = adjust_for_widow_orphan(&lines, take, n as usize);
            }

            // Lines beyond `take` carry their words into the next box. Rebuild
            // the remaining token queue from the overflow lines (flatten back
            // into a single word stream so the next box re-wraps to its width),
            // merging any hyphenation fragments back into whole words.
            let overflow_lines = lines.split_off(take);
            remaining = flatten_lines_to_tokens(overflow_lines, hyph_ctx.as_ref());

            assignments.insert(
                member.id.clone(),
                ChainAssignment {
                    lines,
                    metrics,
                    is_last_member: false,
                },
            );
        }
    }

    assignments
}

/// Distribute a CHAINED markdown source across its members as styled BLOCKS.
///
/// Each [`MdBlock`] is shaped once (per-block font/size/fill via the shared
/// cascade) into a descriptor; this distributor then packs each block's tokens to
/// each member's OWN width (members differ) and tags every resulting [`Line`] with
/// that block's per-line style + height, so a heading and body paragraph keep
/// their own ascent/size while sharing one galley. Inter-block spacing is folded
/// into the LAST line of the previous block's `height_px`; the very first block's
/// space-before is suppressed (no gap at the galley top). When a block boundary
/// lands at a member-box bottom the trailing gap simply ends that box (v1).
///
/// Overflow beyond the last member rides the EXISTING chain overflow path: the
/// last member keeps all remaining lines and `chain_member` raises the existing
/// `text.fit_failed` diagnostic under `overflow="fit"`, so the "add a chained
/// box" guidance persists until the article fits.
struct BlockChainInput<'a> {
    src: &'a TextNode,
    blocks: &'a [zenith_core::MdBlock],
    chain_members: &'a [Member],
    page_block_styles: &'a [zenith_core::BlockStyle],
    doc_styles: ChainDocStyles<'a>,
    direction: TextDirection,
    fonts: &'a dyn FontProvider,
    engine: &'a RustybuzzEngine,
}

fn distribute_block_chain(
    input: BlockChainInput,
    diagnostics: &mut Vec<Diagnostic>,
    assignments: &mut ChainAssignments,
) {
    let BlockChainInput {
        src,
        blocks,
        chain_members,
        page_block_styles,
        doc_styles,
        direction,
        fonts,
        engine,
    } = input;

    // The chain source's base render style (families/size/weight) for the cascade
    // fallback. The returned spans are unused on the block path.
    // Use the base-style resolver (families/size/weight only) — the per-span
    // ResolvedSpan allocation is not needed on the block path.
    let (families, font_size, base_weight) = resolve_chain_base_style(
        src,
        doc_styles.resolved,
        doc_styles.style_map,
        fonts,
        diagnostics,
    );

    let descriptors = shape_source_blocks(
        src,
        blocks,
        ChainSourceShape {
            families: &families,
            node_font_size: font_size,
            base_weight,
            direction,
        },
        BlockStyleEnv {
            resolved: doc_styles.resolved,
            page_block_styles,
            doc_block_styles: doc_styles.doc_block_styles,
        },
        ShapeEnv { engine, fonts },
        diagnostics,
    );

    // The chain's representative metrics = the FIRST block's metrics (used by the
    // baseline-grid snap + as the assignment-level fallback). Per-line style on
    // each Line carries the real per-block values for emit.
    let rep_metrics = descriptors.first().map(|d| d.metrics).unwrap_or_default();

    // Opt-in en-US hyphenation for prose blocks, read from the source node and
    // mirroring the inline chain path: absent → `None` → packing byte-identical.
    // Code blocks never hyphenate (they pass `None` regardless). Break-word stays
    // off, matching the inline chain path's documented behavior.
    let hyph_ctx = if src.hyphenate == Some(true) {
        en_us_hyphenator().map(|dict| HyphenationContext {
            dict: Some(dict),
            engine,
            fonts,
            families: &families,
            hyphen: "-",
            direction,
            break_word: false,
        })
    } else {
        None
    };

    // A FIFO of blocks awaiting placement. Each block's owned tokens are consumed
    // exactly once (no cloning): a straddling block re-queues its overflow tail at
    // the FRONT (re-wrapped to the next member's width). `style` carries the
    // per-line style/metrics/spacing; `is_spacer` marks a horizontal-rule gap.
    struct PendingBlock {
        index: usize,
        tokens: Vec<WordToken>,
        style: LineStyle,
        line_height: f64,
        space_advance: f64,
        space_after_px: f64,
        space_before_px: f64,
        is_spacer: bool,
        left_indent_px: f64,
        decoration: Option<LineDecoration>,
        /// Code blocks render raw — no hyphenation; prose blocks hyphenate when
        /// the source opts in (mirrors the single-box wrap path).
        hyphenate: bool,
    }
    let mut queue: std::collections::VecDeque<PendingBlock> = descriptors
        .into_iter()
        .enumerate()
        .map(|(index, d)| PendingBlock {
            index,
            // A code block (background decoration) renders raw; everything else
            // is prose eligible for hyphenation.
            hyphenate: !matches!(d.decoration, Some(LineDecoration::Background(_))),
            tokens: d.tokens,
            style: d.line_style,
            line_height: d.metrics.line_height,
            space_advance: d.metrics.space_advance,
            space_after_px: d.space_after_px,
            space_before_px: d.space_before_px,
            is_spacer: d.is_spacer,
            left_indent_px: d.left_indent_px,
            decoration: d.decoration,
        })
        .collect();

    let last_member = chain_members.len().saturating_sub(1);

    for (mi, member) in chain_members.iter().enumerate() {
        let mut member_lines: Vec<Line> = Vec::new();
        let mut used_h = 0.0_f64;
        let is_last = mi == last_member;
        // The block index of the previous line in THIS member, the gap-fold
        // target. `None` before any line in this member → no fold at the top.
        let mut prev_block_in_member: Option<usize> = None;
        // The `space_after` of the block that owns the previous line in THIS
        // member, captured when it was placed (its descriptor is consumed by then).
        let mut prev_space_after: f64 = 0.0;

        while let Some(block) = queue.pop_front() {
            // A spacer block (horizontal rule) is ONE empty line of the gap height
            // carrying the rule decoration (drawn centered in its band by emit).
            let mut block_lines: Vec<Line> = if block.is_spacer {
                vec![Line {
                    words: Vec::new(),
                    content_w: 0.0,
                    paragraph: block.index,
                    height_px: block.line_height,
                    line_style: Some(block.style),
                    left_indent_px: block.left_indent_px,
                    decoration: block.decoration,
                }]
            } else {
                // Prose blocks hyphenate when the source opts in; code blocks pass
                // `None` so their raw content is never split. The indent shrinks the
                // packing width so wrapped text stays inside the box (emit applies
                // the matching shift), mirroring the single-box indent slot.
                let pack_hyph = if block.hyphenate {
                    hyph_ctx.as_ref()
                } else {
                    None
                };
                let pack_w = (member.w - block.left_indent_px).max(0.0);
                let mut ls = pack_lines(
                    block.tokens,
                    pack_w,
                    block.space_advance,
                    pack_hyph,
                    block.line_height,
                );
                for l in &mut ls {
                    l.paragraph = block.index;
                    l.line_style = Some(block.style);
                    l.left_indent_px = block.left_indent_px;
                    l.decoration = block.decoration;
                }
                ls
            };

            // Fold the inter-block gap into the previous line of THIS member when
            // this is a NEW block (the prior member-top continuation gets no fold,
            // so the gap that ended the prior box is not double-counted). The gap
            // is `prev.space_after + this.space_before`.
            let gap = match prev_block_in_member {
                Some(prev_idx) if prev_idx != block.index => {
                    prev_space_after + block.space_before_px
                }
                _ => 0.0,
            };

            if is_last {
                // Last member keeps everything; overflow rides chain_member's own
                // `overflow="fit"` check + the assignment carries all leftover.
                if gap > 0.0
                    && let Some(prev_line) = member_lines.last_mut()
                {
                    prev_line.height_px += gap;
                }
                if block_lines.last().is_some() {
                    prev_block_in_member = Some(block.index);
                    prev_space_after = block.space_after_px;
                }
                member_lines.append(&mut block_lines);
                continue;
            }

            // Apply the gap to the previous line (folded into its height) before
            // measuring this block, so the gap counts against the box budget.
            if gap > 0.0
                && let Some(prev_line) = member_lines.last_mut()
            {
                prev_line.height_px += gap;
                used_h += gap;
            }

            // Place lines while the box height allows. A still-empty member always
            // takes at least the first line so content cannot stall.
            let mut placed = 0usize;
            for l in &block_lines {
                if used_h + l.height_px > member.h && !member_lines.is_empty() {
                    break;
                }
                used_h += l.height_px;
                placed += 1;
            }

            if placed == block_lines.len() {
                if block_lines.last().is_some() {
                    prev_block_in_member = Some(block.index);
                    prev_space_after = block.space_after_px;
                }
                member_lines.append(&mut block_lines);
                continue;
            }

            // The block straddles this member boundary: keep `placed` lines here,
            // re-queue the overflow tail (re-wrapped to the NEXT member's width).
            let kept: Vec<Line> = block_lines.drain(..placed).collect();
            member_lines.extend(kept);
            // Merge hyphen fragments back into whole words for prose tails so the
            // next member re-wraps cleanly; code tails carry `None` (never split).
            let tail_hyph = if block.hyphenate {
                hyph_ctx.as_ref()
            } else {
                None
            };
            let tail_tokens = flatten_lines_to_tokens(block_lines, tail_hyph);
            queue.push_front(PendingBlock {
                index: block.index,
                tokens: tail_tokens,
                style: block.style,
                line_height: block.line_height,
                space_advance: block.space_advance,
                space_after_px: block.space_after_px,
                // A continued tail carries NO space-before (the block already
                // started above); its space-after still applies after it ends.
                space_before_px: 0.0,
                is_spacer: false,
                left_indent_px: block.left_indent_px,
                decoration: block.decoration,
                hyphenate: block.hyphenate,
            });
            break;
        }

        assignments.insert(
            member.id.clone(),
            ChainAssignment {
                lines: member_lines,
                metrics: rep_metrics,
                is_last_member: is_last,
            },
        );

        if is_last {
            break;
        }
    }
}

/// Adjust a greedy height-cut `take` (number of lines kept in THIS box, out of
/// `lines`) to honor a widow/orphan minimum of `n` lines per paragraph across
/// the box boundary. Returns the possibly-reduced `take`; lines are only ever
/// moved DOWN into the next box (the greedy flow cannot push lines up).
///
/// The boundary splits a paragraph when the last kept line and the first
/// overflow line share a paragraph index. In that case:
/// - `top_count` = trailing lines of that paragraph kept in THIS box;
/// - `bottom_count` = leading lines of that paragraph in the NEXT box.
///
/// To satisfy the WIDOW rule the next box must start with ≥ `n` lines of the
/// paragraph, so if `bottom_count < n` we move `n - bottom_count` lines down. To
/// satisfy the ORPHAN rule this box must keep ≥ `n` lines of the paragraph, so if
/// the move would leave `top_count < n` we instead move the WHOLE top chunk of
/// the paragraph down (the paragraph then starts cleanly in the next box).
///
/// Degenerate cases (documented): if the adjustment would empty THIS box
/// (`take` → 0) the cut is LEFT as-is, since an empty box is worse than a
/// widow/orphan; likewise a paragraph shorter than `2n` lines cannot satisfy the
/// rule on both sides and falls back to being moved whole (or left, if that
/// empties the box).
fn adjust_for_widow_orphan(lines: &[Line], take: usize, n: usize) -> usize {
    // No straddle when nothing is kept, nothing overflows, or the boundary lines
    // belong to different paragraphs.
    if take == 0 || take >= lines.len() {
        return take;
    }
    let (Some(last_kept), Some(first_over)) = (lines.get(take - 1), lines.get(take)) else {
        return take;
    };
    if last_kept.paragraph != first_over.paragraph {
        return take;
    }
    let para = last_kept.paragraph;

    // Trailing lines of `para` kept in this box.
    let top_count = lines[..take]
        .iter()
        .rev()
        .take_while(|l| l.paragraph == para)
        .count();
    // Leading lines of `para` in the next box.
    let bottom_count = lines[take..]
        .iter()
        .take_while(|l| l.paragraph == para)
        .count();

    let mut new_take = take;
    if bottom_count < n {
        let need = n - bottom_count;
        new_take = take.saturating_sub(need);
    }
    // If the (possible) move still leaves the top side with < n lines of the
    // paragraph, move the whole top chunk down so the paragraph starts fresh.
    let top_after = top_count.saturating_sub(take - new_take);
    if top_after < n {
        new_take = take.saturating_sub(top_count);
    }

    // Never empty this box; if the rule cannot be honored without doing so,
    // leave the greedy cut unchanged (degenerate case).
    if new_take >= 1 { new_take } else { take }
}

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

    /// Build a line list from per-line paragraph indices (words/width are not
    /// read by `adjust_for_widow_orphan`).
    fn lines_with_paragraphs(paras: &[usize]) -> Vec<Line> {
        paras
            .iter()
            .map(|&p| Line {
                words: Vec::new(),
                content_w: 0.0,
                paragraph: p,
                height_px: 0.0,
                line_style: None,
                left_indent_px: 0.0,
                decoration: None,
            })
            .collect()
    }

    /// No straddle (the boundary lines belong to different paragraphs) → the cut
    /// is left exactly where the greedy fit put it.
    #[test]
    fn no_straddle_keeps_take() {
        // take=3: line 2 is paragraph 0, line 3 is paragraph 1 → no straddle.
        let lines = lines_with_paragraphs(&[0, 0, 0, 1, 1, 1]);
        assert_eq!(adjust_for_widow_orphan(&lines, 3, 2), 3);
    }

    /// Orphan: box 1 would keep a lone FIRST line of paragraph 1 (take=4 keeps
    /// [0,0,0,1]). With N=2 that single line is pulled down → take=3.
    #[test]
    fn orphan_single_first_line_pulled_down() {
        let lines = lines_with_paragraphs(&[0, 0, 0, 1, 1, 1]);
        assert_eq!(adjust_for_widow_orphan(&lines, 4, 2), 3);
    }

    /// Widow: the next box would start with a lone LAST line of paragraph 0
    /// (take=5 keeps [0,0,0,0,0], overflow [0,1,...] starts with 1 line of P0).
    /// With N=2 one line is pulled down so the next box starts with 2 lines of P0.
    #[test]
    fn widow_single_last_line_pulled_down() {
        let lines = lines_with_paragraphs(&[0, 0, 0, 0, 0, 0, 1, 1]);
        // take=5 → bottom_count(P0)=1 (line index 5), top_count=5. Pull 1 down.
        assert_eq!(adjust_for_widow_orphan(&lines, 5, 2), 4);
    }

    /// Both sides already satisfy N → no change.
    #[test]
    fn satisfied_both_sides_unchanged() {
        let lines = lines_with_paragraphs(&[0, 0, 0, 0]);
        // take=2: top=2 lines of P0, bottom=2 lines of P0 → fine.
        assert_eq!(adjust_for_widow_orphan(&lines, 2, 2), 2);
    }

    /// Degenerate: honoring the rule would empty the box → leave the cut as-is.
    #[test]
    fn degenerate_would_empty_box_left_as_is() {
        // Whole box is the tail of paragraph 1 (single line), next box continues
        // it. Pulling down would empty the box → unchanged.
        let lines = lines_with_paragraphs(&[1, 1, 1]);
        assert_eq!(adjust_for_widow_orphan(&lines, 1, 2), 1);
    }
}