rustyfi-html 0.1.2

HTML output backend for SATySFi: a reflowable web document, or a layout-faithful serialization of the PDF box/page model
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
//! `PureHorzBox` → inline HTML ("Inline level"), appending into an
//! already-open paragraph's (or inline frame's) text buffer — never
//! absolutely positioned, never carrying an x/y of its own; the browser
//! lays every span out in normal inline flow.
//!
//! Slice 1 renders `InnerString` (styled + escaped text), the three glue
//! variants (collapsed to a plain space — the browser re-breaks, so the
//! exact stretch/shrink amounts have no reflow meaning), `Discretionary`
//! (a soft hyphen), and `Frame` (a real inline `<span>`, contents recursed).
//!
//! Slice 2 (design doc §4/§6 "S2") replaces the `Math`/`Graphics` PLACEHOLDER
//! `<span>`s with real, self-contained inline `<svg>` — see
//! [`emit_math_svg`]/[`emit_graphics_box`] — and gives `Frame` real `<a
//! href>`/`id=` treatment when its `DecoId` matches an observed link/
//! destination (`Ctx::links`/`Ctx::dests`, sourced from `DocumentValue::
//! reflow_links`/`reflow_dests`). `GraphicsOuter`/`Image`/`Footnote` remain
//! inert PLACEHOLDER `<span>`s (`GraphicsOuter` in particular is a
//! lang-side-only DEFERRED callback — `resolve_outer_graphics_in_contents`,
//! `rustyfi-lang/src/primitives.rs:3917`, always resolves it to a plain
//! `Graphics` box during `line-break`, well before `reflow_source` is
//! captured, so this arm is realistically unreachable; kept as an honest
//! placeholder rather than assumed-dead code).
//! `HookPageBreak`/`FrameMarker` render nothing (no reflow meaning, same as
//! both the faithful HTML writer and the PDF writer's own wildcard arms).
//!
//! Slice 3 (design doc §6 "S3", `structure.rs`'s doc comment) replaces the
//! `Tabular` PLACEHOLDER `<span>` with a real `<table>` — see this module's
//! own `Tabular` arm below for why it delegates to `structure::render_table`
//! (`block.rs` handles the common top-level case directly, since a `<table>`
//! is block-level and needs to flush the surrounding paragraph first; this
//! arm is only the fallback for a `Tabular` nested inside inline content).
//!
//! Slice 4 ("Inline level") handles the new `InlineMark` box:
//! `EmphStart`/`EmphEnd` open/close a real `<em>`/`<strong>` (via
//! `Ctx::emph_stack`, since `EmphEnd` alone doesn't say which tag to close —
//! see that field's doc comment), and `BulletStart`/`BulletEnd` fence a
//! drawn bullet/number glyph run so it renders NOTHING
//! (`Ctx::bullet_suppress`) — the real marker comes from the `<ul>`/`<ol>`
//! `block.rs` now emits instead.

use std::fmt::Write as _;

use rustyfi_backend::{
    AnnotAction, Color, GraphicsElem, HorzStringInfo, InlineMarkKind, MathGlyph, PureHorzBox,
};

use super::Ctx;
use crate::image;

/// Append `bx`'s reflow rendering to `out`. Never touches `out`'s
/// surrounding whitespace/paragraph bookkeeping — that is the caller's
/// (`block.rs`'s) job.
pub(crate) fn emit_inline(out: &mut String, bx: &PureHorzBox, ctx: &Ctx) {
    match bx {
        // S4: handled FIRST, unconditionally of `ctx.bullet_suppress` below
        // — a `BulletEnd` reached WHILE suppressed must still clear the
        // counter, and an `EmphStart`/`EmphEnd` reached while suppressed
        // (should not happen — `itemize.satyh` never nests emphasis inside
        // its own bullet fence — but stays correct regardless) must still
        // keep the tag stack balanced.
        PureHorzBox::InlineMark(kind) => match kind {
            InlineMarkKind::EmphStart { strong } => {
                close_run(out, ctx);
                ctx.emph_stack.borrow_mut().push(*strong);
                out.push_str(if *strong { "<strong>" } else { "<em>" });
            }
            InlineMarkKind::EmphEnd => {
                close_run(out, ctx);
                // An unmatched `EmphEnd` (should not happen) closes `</em>`
                // rather than panicking.
                let strong = ctx.emph_stack.borrow_mut().pop().unwrap_or(false);
                out.push_str(if strong { "</strong>" } else { "</em>" });
            }
            InlineMarkKind::BulletStart => {
                *ctx.bullet_suppress.borrow_mut() += 1;
            }
            InlineMarkKind::BulletEnd => {
                let mut n = ctx.bullet_suppress.borrow_mut();
                *n = n.saturating_sub(1);
            }
        },

        // `inline-frame-breakable` does NOT build a `Frame`: it splices its
        // contents between a marker PAIR so the frame can split across lines,
        // which means the wrapper has to be opened and closed positionally
        // rather than around a recursion. Same lookup as the `Frame` arm
        // below, keyed on the MARKER's own `DecoId`. Without this arm every
        // `\href`/`\ref` that goes through the breakable frame — which is
        // how `annot.satyh` writes them — renders its text unwrapped, with
        // no error anywhere.
        //
        // Handled here, ahead of the `bullet_suppress` guard below, for the
        // same reason `InlineMark` is: a marker skipped while suppressed
        // would leave the stack unbalanced and mis-close a later wrapper. A
        // link inside a bullet fence should not happen, but the tags stay
        // balanced regardless — while suppressed the stack is still
        // maintained and only the emission is dropped.
        PureHorzBox::InlineFrameMarker { id, end, .. } => {
            let suppressed = *ctx.bullet_suppress.borrow() > 0;
            close_run(out, ctx);
            if *end {
                let close = ctx
                    .iframe_stack
                    .borrow_mut()
                    .pop()
                    .map_or("</span>", |(_, close)| close);
                if !suppressed {
                    out.push_str(close);
                }
            } else {
                let (open, reopen, close) = wrapper_tags(id, ctx);
                if !suppressed {
                    out.push_str(&open);
                }
                ctx.iframe_stack.borrow_mut().push((reopen, close));
            }
        }

        // S4: while a `BulletStart`/`BulletEnd` fence is open, every OTHER
        // box (the bullet's `Graphics` circle / the enumerate index's
        // `InnerString` digit, and anything else that happened to ride
        // along) renders nothing — the real marker comes from the `<ul>`/
        // `<ol>` `block.rs` now emits. Matched here, ahead of every
        // concrete arm below, via a guarded wildcard (still fully
        // exhaustive together with the explicit arms that follow it — see
        // `rustc`'s own exhaustiveness check, which accepts this).
        _ if *ctx.bullet_suppress.borrow() > 0 => {}

        PureHorzBox::InnerString { info, text, .. } => emit_run(out, info, text, ctx),

        // Glue does NOT become a space here — it is RECORDED, and judged
        // once the following character is known. The box stream puts glue
        // between every pair of CJK characters and inside every hyphenatable
        // Latin word, so "glue means space" (what this arm used to do)
        // rendered Japanese as `研 究 計 画` and `\LaTeX` as `L AT EX`. See
        // `text.rs`'s doc comment for the whole argument and `wants_space`
        // for the rule.
        PureHorzBox::OuterEmpty { natural, .. } => ctx.note_glue(natural.0),
        // `inline-fil`: infinite stretch, no natural width. Never a space;
        // `block.rs` reads its POSITION instead, as the alignment signal it
        // is (leading + trailing fil = centred, leading only = flush right).
        PureHorzBox::OuterFil => ctx.note_glue(0.0),
        // `inline-skip`: an explicit, deliberately-sized, non-breakable gap.
        // Above a visible threshold it keeps its width as an inline-block
        // strut (intrinsic sizing, not positioning — the same licence math
        // and graphics have); below it, it is a kern and goes through the
        // ordinary glue rule, which drops it.
        PureHorzBox::FixedEmpty { width } => {
            if width.0 >= HSKIP_MIN_PT {
                ctx.resolve_glue(out, None);
                close_run(out, ctx);
                let _ = write!(
                    out,
                    "<span class=\"hskip\" style=\"width:{}pt;\"></span>",
                    width.0
                );
            } else {
                // A kern: recorded as zero-width glue, i.e. as nothing. A
                // sub-2pt `inline-skip` is a micro-adjustment, never a word
                // space — word spaces arrive as `OuterEmpty`.
                ctx.note_glue(0.0);
            }
        }

        // A break point that may or may not be taken. When it carries text
        // in `pre_break` it is a real hyphenation point (the pattern
        // dictionary's, `Latex&shy;Cmds`) and a soft hyphen is exactly
        // right: the browser re-breaks and shows the hyphen only if it
        // breaks there. When `pre_break` is bare glue it is a UAX#14 chunk
        // boundary — a soft hyphen there would invite the browser to
        // hyphenate `Con-tributors` at a point the dictionary never
        // sanctioned, and between two CJK characters it would be nonsense —
        // so it goes through the ordinary glue rule instead.
        PureHorzBox::Discretionary { pre_break, .. } => {
            if pre_break_carries_text(pre_break) {
                out.push_str("&shy;");
            } else {
                ctx.note_glue(glue_width(pre_break));
            }
        }

        // A real inline frame: no atomic-width fitting to preserve (that
        // was only ever needed for the eager line-breaker) — just recurse
        // its contents into one wrapper for CSS-hook purposes.
        //
        // S2 (design doc §4 "Links/metadata"): if THIS frame's `DecoId`
        // matches an observed `register-link-to-uri`/`-to-location` call
        // (`ctx.links`, `annot.satyh`'s `\href`), wrap the contents in a
        // real `<a href>` instead of a plain `<span>` — `Uri` maps to the
        // literal URL, `GotoName` to an in-document `#anchor` (the matching
        // destination is placed by `block.rs`'s `FrameStart`/`ctx.dests`
        // lookup, or by this SAME arm's `dest` fallback below when the
        // named-destination frame happens to be inline rather than block).
        // Falls back to `ctx.dests` (a `register-location-frame` used
        // inline rather than as a block frame) for a plain `id=` anchor
        // when there's no link action, then to the Slice-1 inert `<span>`.
        PureHorzBox::Frame { deco, contents, .. } => {
            let (open, _, close) = wrapper_tags(deco, ctx);
            close_run(out, ctx);
            out.push_str(&open);
            for (_, cbx) in contents {
                emit_inline(out, cbx, ctx);
            }
            close_run(out, ctx);
            out.push_str(close);
        }

        // Math is flattened to positioned glyphs at eval time (design doc
        // §4) — no fraction/sub/sup structure survives to render as MathML,
        // so (Slice 2, design doc §4's "the honest option") this renders the
        // SAME way the faithful backend's approximation does: each glyph as
        // positioned text, each rule (fraction bar/radical) as an SVG path
        // — but bundled into ONE self-contained, intrinsically-sized inline
        // `<svg>` (see [`emit_math_svg`]) instead of the faithful mode's
        // page-absolute `<span>`s, since reflow has no page to be absolute
        // WITHIN.
        PureHorzBox::Math {
            width,
            height,
            depth,
            glyphs,
            rules,
        } => emit_math_svg(out, width.0, height.0, depth.0, glyphs, rules, ctx),

        // Slice 2 (design doc §6/§4 "reuse svg::emit_graphics verbatim"):
        // real inline SVG, sized to the box's own metrics — see
        // [`emit_graphics_box`].
        PureHorzBox::Graphics {
            width,
            height,
            depth,
            elems,
            origin_independent: _,
        } => emit_graphics_box(out, width.0, height.0, depth.0, elems, ctx),

        // `GraphicsOuter` is a DEFERRED lang-side callback (`GraphicsFnId`,
        // resolved only by `resolve_outer_graphics_in_contents` at
        // `line-break` time, well before `reflow_source` is captured — see
        // this module's doc comment); the backend has no way to run it, same
        // limitation the faithful writer has (its own `emit_box` has no arm
        // for this variant at all, silently matching its wildcard). Kept as
        // an honest placeholder rather than silently dropped.
        PureHorzBox::GraphicsOuter { .. } => {
            open_opaque(out, ctx);
            out.push_str(
                "<span class=\"gfx-placeholder\" title=\"unresolved inline-graphics-outer (lang-side callback)\"></span>",
            );
        }
        // A real `<img>` with a self-contained data URI, sized in the
        // document's own points but capped at the column width so a figure
        // wider than a narrow viewport shrinks instead of overflowing
        // (`css.rs`'s `img.img` rule) — the reflowable counterpart of the
        // faithful backend's absolutely-positioned `<img>`, sharing
        // `crate::image::data_uri` verbatim with it.
        PureHorzBox::Image {
            width,
            height,
            image,
        } => {
            open_opaque(out, ctx);
            match ctx.images.get(image.0) {
                // `load-pdf-image`: an imported PDF page carries no raster
                // samples at all (`ImageResource::pdf`), and rasterizing one
                // is out of scope for an HTML writer, so this keeps an
                // honestly-labelled box at the right size rather than
                // emitting a degenerate 0x0 `<img>`. The faithful backend
                // does exactly the same.
                Some(res) if res.pdf.is_some() => {
                    let _ = write!(
                        out,
                        "<span class=\"pdf-image\" style=\"width:{}pt; height:{}pt;\" \
                         title=\"embedded PDF page (not rasterized)\"></span>",
                        width.0, height.0,
                    );
                }
                // Placed more than once: its bytes go into the stylesheet
                // ONCE (`css.rs`'s `shared_image_rules`) and every placement
                // is a sized box referencing that rule. A data URI is
                // typically hundreds of kilobytes, so repeating it per
                // placement is the difference between a 13 MB file and a
                // 1.9 MB one for a manual that shows the same two figures
                // seventeen times. The element is `aria-hidden` rather than
                // an `<img>`, which costs nothing here: SATySFi carries no
                // alt text, so the `<img>` below is `alt=""` — decorative —
                // and the two are equivalent to a screen reader.
                Some(_) if ctx.image_sharing(image.0).1 => {
                    let canon = ctx.image_sharing(image.0).0;
                    let mut shared = ctx.shared_images.borrow_mut();
                    if !shared.contains(&canon) {
                        shared.push(canon);
                    }
                    drop(shared);
                    let _ = write!(
                        out,
                        "<span class=\"img shared-img-{canon}\" style=\"width:{}pt; height:{}pt;\" \
                         aria-hidden=\"true\"></span>",
                        width.0, height.0,
                    );
                }
                Some(res) => {
                    let _ = write!(
                        out,
                        "<img class=\"img\" src=\"{}\" style=\"width:{}pt; height:{}pt;\" alt=\"\">",
                        image::data_uri(res),
                        width.0,
                        height.0,
                    );
                }
                // Out-of-range `ImageId` — should not happen; the faithful
                // writer and the PDF writer both skip rather than panic.
                None => {}
            }
        }
        // S3 ("S3", `structure.rs`'s "Tables — genuinely recoverable"): a
        // real `<table>`. `block.rs`'s own `VertBox::Line` walk already
        // special- cases the common top-level case (flushing the open
        // paragraph first, since a `<table>` is block-level); THIS arm is
        // the fallback for a `Tabular` nested inside inline content this
        // module recurses into on its own (a `Frame`'s `contents`, or a
        // table cell that itself contains a nested `Tabular`) — no
        // surrounding paragraph to flush here, so no `extra_attrs` margin.
        PureHorzBox::Tabular(tab) => {
            open_opaque(out, ctx);
            super::structure::render_table(out, tab, "", ctx)
        }
        // A footnote has nowhere to be collected TO in a continuous
        // document — there is no page foot any more — so it becomes a
        // numbered reference here and its body is queued for `block.rs`'s
        // `flush_para` to place as an `<aside>` immediately after the
        // paragraph that referenced it. See the `reflow` module's doc
        // comment for why "just after the paragraph" was chosen over
        // "collected at the end".
        PureHorzBox::Footnote { block } => {
            open_opaque(out, ctx);
            let n = ctx.footnote_seq.get() + 1;
            ctx.footnote_seq.set(n);
            // Render the body NOW, at the point the marker rides, so it sees
            // the surrounding context — but into its own buffer, and with
            // the inline-flow state saved/restored around it, since the
            // footnote's own last character must not decide the spacing of
            // the word after the reference in the main text.
            let saved = (ctx.pending_glue.take(), ctx.last_char.take());
            // Park the queue too: the nested walk drains whatever it finds
            // there when it finishes, and an earlier sibling footnote from
            // this same paragraph must not end up nested inside this one.
            let parked = std::mem::take(&mut *ctx.footnotes.borrow_mut());
            let mut body = String::new();
            super::block::walk_vboxes(&mut body, block, ctx);
            ctx.pending_glue.set(saved.0);
            ctx.last_char.set(saved.1);
            let mut queue = ctx.footnotes.borrow_mut();
            *queue = parked;
            queue.push((n, body));
            drop(queue);
            // An EMPTY anchor, not a visible marker.
            //
            // The document has already typeset its own reference marker —
            // `stdjabook`'s `\footnote` sets a superscript `*1` in the text
            // immediately after this box — so emitting a numbered `<sup>`
            // here put `1*1` on the page, and the note's body then repeated
            // the document's number a third time. What is missing is not a
            // marker but a link TARGET, which is all this is; the
            // `<aside>`'s back-link points at it. Forward navigation is not
            // worth duplicating the marker for, since the note lands a
            // couple of lines below rather than pages away.
            let _ = write!(out, "<span class=\"fnref\" id=\"fnref-{n}\"></span>");
        }

        // `EmbeddedBlock` is handled one level up, in `block.rs`'s own
        // per-`Line`-contents loop (it needs to CLOSE the open paragraph,
        // which this function's `&mut String` signature has no way to do)
        // — unreachable in practice, kept as an explicit inert arm rather
        // than a silent catch-all so a future new `PureHorzBox` variant
        // still forces a compile error here instead of silently falling
        // through.
        PureHorzBox::EmbeddedBlock { .. } => {}

        // No reflow meaning (zero-width markers/hooks; matches the
        // faithful writer's own wildcard treatment of these two).
        PureHorzBox::HookPageBreak { .. } | PureHorzBox::FrameMarker { .. } => {}
    }
}

/// A `FixedEmpty` (`inline-skip`) at least this wide (pt) survives as a real
/// sized strut; anything narrower is a KERN and renders as nothing at all.
///
/// Two points separates the two populations cleanly in the corpus. Above it
/// sit the deliberate gaps — a paragraph indent (one em, 10.56pt), a table
/// cell's padding (6pt), the space after a section number (10pt) — which a
/// reader would miss. Below it sit micro-adjustments: the `\LaTeX` logo's
/// four kerns, and the 1pt gaps between the dots of a table-of-contents
/// leader, of which `enumitem`'s manual has some hundreds. Rendering those
/// as struts broke a leader into one `<span>` per dot; rendering them as
/// spaces would be wider than the kern they stand for. Nothing is right for
/// both.
const HSKIP_MIN_PT: f64 = 2.0;

/// One `InnerString` run.
///
/// The text is written as TEXT: a run set in the document's body style (see
/// `text::BodyStyle`, which `css.rs` puts on `body`) gets no element around
/// it at all, so a paragraph of ordinary prose serialises as ordinary prose
/// rather than as one `<span style="font-size:10.56pt">` per syllable. Only
/// the properties that genuinely differ from the body style are emitted, and
/// the size as an `em` RATIO rather than an absolute point size, so the
/// whole document rescales from the single value on `body`.
///
/// Still no `left`/`top`/`position` — this is flowing content, not a placed
/// box; `vertical-align` (not `position`) handles a non-zero `rising`, since
/// it needs no positioned ancestor and composes with the inline flow.
fn emit_run(out: &mut String, info: &HorzStringInfo, text: &str, ctx: &Ctx) {
    if text.is_empty() {
        return;
    }
    ctx.resolve_glue(out, text.chars().next());
    ctx.last_char.set(text.chars().next_back());
    ctx.mono_run.set(ctx.is_monospace(Some(info.font)));

    let mut style = String::new();
    if !ctx.body.matches(info.font, info.size.0) {
        if Some(info.font) != ctx.body.font {
            if let Some(stack) = ctx.font_family_for(info.font) {
                let _ = write!(style, "font-family:{stack};");
            }
        } else {
            // Same face as the body, so the `@font-face` rule for it is
            // needed even though this run names no family of its own.
            let _ = ctx.font_family_for(info.font);
        }
        if (info.size.0 - ctx.body.size).abs() >= 0.005 {
            let _ = write!(style, "font-size:{:.4}em;", info.size.0 / ctx.body.size);
        }
    } else {
        // Body-styled: still record the face as used, so `font_face_rules`
        // embeds it for the `body` rule that names it.
        let _ = ctx.font_family_for(info.font);
    }
    // Non-black only, mirroring the faithful writer's own guard, so a plain
    // black run stays unwrapped.
    if info.color != Color::Gray(0.0) {
        let _ = write!(style, "color:{};", crate::svg::css_color(info.color));
    }
    if info.rising.0 != 0.0 {
        let _ = write!(style, "vertical-align:{}pt;", info.rising.0);
    }

    let escaped = crate::escape_html(text);
    if style.is_empty() {
        close_run(out, ctx);
        out.push_str(&escaped);
        return;
    }
    // Same style as the span already open: keep writing into it. This is
    // what turns the box stream's one-`InnerString`-per-CJK-character (and
    // one-per-hyphenation-chunk) into a single span of readable text.
    let mut open = ctx.open_run.borrow_mut();
    match open.as_deref() {
        Some(current) if current == style => {}
        _ => {
            if open.is_some() {
                out.push_str("</span>");
            }
            let _ = write!(out, "<span class=\"run\" style=\"{style}\">");
            *open = Some(style);
        }
    }
    out.push_str(&escaped);
}

/// Close the `<span class="run">` left open by [`emit_run`], if any. Called
/// by everything that writes something which is not part of the run's text —
/// a wrapper tag, a strut, an `<svg>`, an `<img>`, the end of a paragraph or
/// a table cell. A space and a soft hyphen do NOT call it: they carry no
/// style and belong inside the word.
pub(crate) fn close_run(out: &mut String, ctx: &Ctx) {
    if ctx.open_run.borrow_mut().take().is_some() {
        out.push_str("</span>");
    }
}

/// The opening and closing tags a decorated inline region gets, from its
/// `DecoId` alone. Shared by the `Frame` arm (a wrapper around a recursion)
/// and the `InlineFrameMarker` arm (the same wrapper, opened and closed
/// positionally because `inline-frame-breakable` splices rather than nests)
/// so the two can never disagree about what a given `DecoId` means:
///
/// - an observed `register-link-to-uri`/`-to-location`
///   (`Ctx::links`, `annot.satyh`'s `\href`) → a real `<a href>`, `Uri` to
///   the literal URL and `GotoName` to an in-document `#anchor`;
/// - an observed `register-destination` (`Ctx::dests`,
///   `register-location-frame`) → a plain `<span>` carrying the `id=` that
///   anchor lands on;
/// - neither → an inert `<span class="iframe">`, kept as a CSS hook.
/// Returns `(open, reopen, close)`. `reopen` differs from `open` only for a
/// destination wrapper, whose `id=` must appear once and only once even if
/// the region has to be split across a paragraph boundary
/// (`Ctx::iframe_stack`).
fn wrapper_tags(deco: &rustyfi_backend::DecoId, ctx: &Ctx) -> (String, String, &'static str) {
    if let Some(action) = ctx.links.get(deco) {
        let href = match action {
            AnnotAction::Uri(uri) => crate::escape_html(uri),
            AnnotAction::GotoName(name) => format!("#{}", crate::escape_html(name)),
        };
        let tag = format!("<a class=\"link\" href=\"{href}\">");
        (tag.clone(), tag, "</a>")
    } else if let Some(name) = ctx.dests.get(deco) {
        (
            format!(
                "<span class=\"iframe\" id=\"{}\">",
                crate::escape_html(name)
            ),
            "<span class=\"iframe\">".to_string(),
            "</span>",
        )
    } else {
        (
            "<span class=\"iframe\">".to_string(),
            "<span class=\"iframe\">".to_string(),
            "</span>",
        )
    }
}

/// Close every inline wrapper this block opened, innermost first, and leave
/// the stack in place so [`reopen_wrappers`] can restore them. Called by
/// `block.rs` when a paragraph is flushed mid-wrapper.
///
/// `base` is the stack depth its `walk_vboxes` was entered at, so only the
/// wrappers THIS block opened are closed. A nested walk — an
/// `EmbeddedBlock`, a footnote body, a table cell — runs with the enclosing
/// paragraph already flushed but its wrapper stack deliberately still
/// standing; without the base it would emit that paragraph's closing tags a
/// second time, inside the nested block.
pub(crate) fn close_open_wrappers(out: &mut String, ctx: &Ctx, base: usize) {
    close_run(out, ctx);
    for (_, close) in ctx.iframe_stack.borrow().iter().skip(base).rev() {
        out.push_str(close);
    }
}

/// Re-open, outermost first, every wrapper [`close_open_wrappers`] closed.
pub(crate) fn reopen_wrappers(out: &mut String, ctx: &Ctx, base: usize) {
    for (reopen, _) in ctx.iframe_stack.borrow().iter().skip(base) {
        out.push_str(reopen);
    }
}

/// Does a `Discretionary`'s `pre_break` carry a visible character (the
/// hyphenation dictionary's hyphen), as opposed to bare glue?
fn pre_break_carries_text(pre_break: &[PureHorzBox]) -> bool {
    pre_break.iter().any(|b| match b {
        PureHorzBox::InnerString { text, .. } => !text.is_empty(),
        _ => false,
    })
}

/// The total natural width of a `Discretionary`'s `pre_break` glue, fed to
/// the ordinary glue rule when there is no hyphen to show.
fn glue_width(pre_break: &[PureHorzBox]) -> f64 {
    pre_break
        .iter()
        .map(|b| match b {
            PureHorzBox::OuterEmpty { natural, .. } => natural.0,
            PureHorzBox::FixedEmpty { width } => width.0,
            _ => 0.0,
        })
        .sum()
}

/// Write an opaque, non-textual inline element (`<svg>`, `<img>`,
/// `<table>`, a footnote reference): settle any pending glue against "no
/// following character", then forget the last character, since the next
/// glue has nothing textual on its left to be judged against.
fn open_opaque(out: &mut String, ctx: &Ctx) {
    ctx.resolve_glue(out, None);
    close_run(out, ctx);
    ctx.last_char.set(None);
}

/// Slice 2 (design doc §4 "Graphics — inline SVG, reuse `svg::emit_graphics`
/// verbatim"): wrap a graphics-bearing box's `elems` in an intrinsically
/// sized `<span>` (`position:relative; display:inline-block`, sized to the
/// box's own `width×(height+depth)` and baseline-aligned via
/// `vertical-align:-depth`) and reuse [`crate::svg::emit_graphics`] UNCHANGED
/// inside it, anchored at the wrapper's own top-left `(0, height)` — exactly
/// the design doc's "supplies its own anchor (0,0 for an inline-block
/// wrapper)". [`crate::svg::emit_graphics`]'s own `<svg>` carries
/// `position:absolute; left:0; top:0`, which is why the WRAPPER (not the
/// page) must be `position:relative`: that scopes the absolute positioning
/// to this one inline box, so it composes with normal flow instead of
/// escaping to the nearest positioned ancestor (which could be the `.doc`
/// root, or nothing at all) — this is the one place `position:absolute`
/// legitimately appears in this module's output, and it never affects
/// block-level layout (the design doc's own "inline SVG for math/graphics is
/// fine — that's intrinsic sizing, not page positioning").
///
/// `nested` (for `GraphicsElem::Text`/`draw-text`, the one arm that steps
/// outside the local coordinate frame — see `svg.rs`'s own doc comment)
/// re-enters THIS module's [`emit_inline`] rather than the faithful writer's
/// page-absolute `emit_box`, since reflow has no page coordinates to place
/// nested content at; the `_x`/`_y` callback args are therefore unused here
/// (a `draw-text` run's nested boxes render inline, at their natural flow
/// position within the wrapper, not at their SVG-local point — a documented
/// approximation for this rare construction, same spirit as the faithful
/// mode's own `Math.glyph.gid` approximation).
fn emit_graphics_box(
    out: &mut String,
    width: f64,
    height: f64,
    depth: f64,
    elems: &[GraphicsElem],
    ctx: &Ctx,
) {
    if elems.is_empty() {
        return;
    }
    // A graphics box whose every element is a `draw-text` DRAWS nothing: the
    // `<svg>` comes out with an empty `<g>` and all the content goes to
    // `nested`. Emitting the wrapper anyway reserved the box's full size a
    // second time, on top of the content's own — `easytable` wraps each table
    // in exactly this shape, and every table in a document arrived under a
    // table-sized rectangle of blank space.
    if elems.iter().all(is_pure_text) {
        // The overlaid halves of one table are visible together only here —
        // see `Ctx::tabular_rules`. Collect any rules-only tabular's rules
        // before emitting, and drop them again after, so the pairing can
        // never reach an unrelated table later in the document.
        let pushed = collect_overlaid_rules(elems, ctx);
        let mut nested = String::new();
        emit_text_only(&mut nested, elems, ctx);
        ctx.tabular_rules.borrow_mut().truncate(pushed);
        out.push_str(&nested);
        return;
    }
    open_opaque(out, ctx);
    let total_h = height + depth;
    let _ = write!(
        out,
        "<span class=\"gfx\" style=\"position:relative; display:inline-block; \
         width:{width}pt; height:{total_h}pt; vertical-align:{}pt;\">\n",
        -depth,
    );
    let mut nested = String::new();
    crate::svg::emit_graphics(
        out,
        elems,
        width,
        height,
        depth,
        0.0,
        height,
        &mut |_svg, cbx, _x, _y| emit_nested_text(&mut nested, cbx, ctx),
    );
    out.push_str(&nested);
    out.push_str("</span>\n");
}

/// Whether `elem` contributes no ink of its own — a `draw-text`, or a group
/// containing only those. `Group`/`Clip` recurse so a `unite-graphics` of
/// text runs is recognised too.
fn is_pure_text(elem: &GraphicsElem) -> bool {
    match elem {
        GraphicsElem::Text { .. } => true,
        GraphicsElem::Group(inner) | GraphicsElem::Clip(_, inner) => inner.iter().all(is_pure_text),
        _ => false,
    }
}

/// Record every rules-bearing `Tabular` in this overlay on
/// [`Ctx::tabular_rules`], returning the stack depth to truncate back to.
fn collect_overlaid_rules(elems: &[GraphicsElem], ctx: &Ctx) -> usize {
    let base = ctx.tabular_rules.borrow().len();
    walk_tabulars(elems, &mut |tab| {
        if !tab.rules.is_empty() {
            ctx.tabular_rules.borrow_mut().push((
                tab.width.0,
                tab.height.0,
                tab.rules.clone(),
            ));
        }
    });
    base
}

/// Visit every `Tabular` reachable through a text-only graphics group's
/// nested boxes.
fn walk_tabulars(elems: &[GraphicsElem], f: &mut impl FnMut(&rustyfi_backend::TabularBox)) {
    for elem in elems {
        match elem {
            GraphicsElem::Text { contents, .. } => {
                for (_, bx) in contents {
                    if let PureHorzBox::Tabular(tab) = bx {
                        f(tab);
                    }
                }
            }
            GraphicsElem::Group(inner) | GraphicsElem::Clip(_, inner) => walk_tabulars(inner, f),
            _ => {}
        }
    }
}

/// The counterpart of [`is_pure_text`]: emit those runs' contents inline, in
/// document order, with no wrapper of their own.
fn emit_text_only(out: &mut String, elems: &[GraphicsElem], ctx: &Ctx) {
    for elem in elems {
        match elem {
            GraphicsElem::Text { contents, .. } => {
                for (_, cbx) in contents {
                    emit_nested_text(out, cbx, ctx);
                }
            }
            GraphicsElem::Group(inner) | GraphicsElem::Clip(_, inner) => {
                emit_text_only(out, inner, ctx)
            }
            _ => {}
        }
    }
}

/// `draw-text`'s nested boxes, rendered for the reflow backend.
///
/// They are collected into a SIDE buffer and appended after the `</svg>`,
/// never written where `svg::emit_graphics`'s callback offers them — which
/// is inside the `<svg>`'s `<g>`. The faithful backend can put them there
/// because everything it emits is an absolutely-positioned page-level
/// element; here they are `<span>`s and `<a>`s of flowing text, and an HTML
/// element inside `<svg>` outside a `<foreignObject>` is not valid markup at
/// all — the browser's parser closes the `<svg>` at the first one and the
/// rest of the drawing escapes into the document.
///
/// The consequence is that a `draw-text` run's text sits AFTER its drawing
/// rather than at its point within it. That is the documented approximation
/// this backend already made for the construct (there are no page
/// coordinates to place it at); it is now merely well-formed.
fn emit_nested_text(nested: &mut String, bx: &PureHorzBox, ctx: &Ctx) {
    emit_inline(nested, bx, ctx);
    close_run(nested, ctx);
}

/// Slice 2 (design doc §4 "Math"): MathML is not recoverable (structure is
/// flattened to positioned glyphs by `read_math`/`layout_math_value` well
/// before any box exists), so this renders the SAME honest approximation the
/// faithful backend does — each glyph as positioned text, each `rules`
/// element (fraction bar/radical) as an SVG path — bundled into ONE
/// self-contained, intrinsically-sized inline `<svg>` (the design doc's
/// "inline `<svg>` sized to the box").
///
/// Two sub-layers, both anchored at the SAME wrapper `(0,0)` top-left:
/// - **Glyphs**: native SVG `<text>` elements, positioned directly in the
///   `<svg>`'s own native (y-DOWN) coordinate space — `MathGlyph.dx`/`dy`
///   are box-local y-**up** offsets from the box's own baseline (the same
///   convention `GraphicsElem::Path` points use, confirmed by the faithful
///   writer's `ty - g.dy.0` arithmetic, `lib.rs`'s `Math` arm), so a local
///   `(dx, dy)` lands at SVG-native `(dx, height - dy)` — computed BY HAND
///   here (not via a `<g transform>` flip) specifically so `<text>` glyphs
///   are never inside a `scale(1,-1)` group, which would render them
///   MIRRORED upside-down (SVG text has no orientation-independence the way
///   a filled path does).
/// - **Rules**: [`crate::svg::emit_graphics`] reused VERBATIM (same call
///   shape as [`emit_graphics_box`]) for `rules` — these ARE orientation-
///   independent paths, so they go through the normal `<g transform>` flip
///   this helper already implements.
fn emit_math_svg(
    out: &mut String,
    width: f64,
    height: f64,
    depth: f64,
    glyphs: &[MathGlyph],
    rules: &[GraphicsElem],
    ctx: &Ctx,
) {
    if glyphs.is_empty() && rules.is_empty() {
        return;
    }
    open_opaque(out, ctx);
    let total_h = height + depth;
    let _ = write!(
        out,
        "<span class=\"math\" style=\"position:relative; display:inline-block; \
         width:{width}pt; height:{total_h}pt; vertical-align:{}pt;\">\n\
         <svg class=\"math-glyphs\" style=\"position:absolute; left:0; top:0; overflow:visible;\" \
         width=\"{width}pt\" height=\"{total_h}pt\" viewBox=\"0 0 {width} {total_h}\">\n",
        -depth,
    );
    for g in glyphs {
        let x = g.dx.0;
        let y = height - g.dy.0 - g.info.rising.0;
        let mut style = format!("font-size:{}pt;", g.info.size.0);
        if let Some(stack) = ctx.font_family_for(g.info.font) {
            style.push_str(&format!("font-family:{stack};"));
        }
        if g.info.color != Color::Gray(0.0) {
            style.push_str(&format!("fill:{};", crate::svg::css_color(g.info.color)));
        }
        let _ = write!(
            out,
            "<text x=\"{x}\" y=\"{y}\" style=\"{style}\">{}</text>\n",
            crate::escape_html(&g.text),
        );
    }
    out.push_str("</svg>\n");
    let mut nested = String::new();
    if !rules.is_empty() {
        crate::svg::emit_graphics(
            out,
            rules,
            width,
            height,
            depth,
            0.0,
            height,
            &mut |_svg, cbx, _x, _y| emit_nested_text(&mut nested, cbx, ctx),
        );
    }
    out.push_str(&nested);
    out.push_str("</span>\n");
}