rustyfi-html 0.1.4

HTML output backend for SATySFi: a reflowable, semantic web document
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
//! `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
//! the PDF writer's own wildcard arm).
//!
//! 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);
            }
            // Everything after this on the line is the LINE BREAKER's hyphen,
            // not the author's. `block.rs` undoes it when it rejoins the
            // lines; all this arm does is say so.
            InlineMarkKind::BreakHyphen => ctx.break_hyphen.set(true),
        },

        // `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
        // each glyph as positioned text and each rule (fraction bar/radical)
        // as an SVG path, bundled into ONE self-contained,
        // intrinsically-sized inline `<svg>` (see [`emit_math_svg`]) — there
        // is no page here to position a glyph WITHIN, so the drawing carries
        // its own coordinate space.
        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 PDF 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), with the bytes inlined by
        // `crate::image::data_uri`.
        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>`.
                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 PDF writer
                // skips rather than panics, and so does this.
                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 PDF
        // 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) {
        // A run in the body's OWN face names no family of its own — the
        // `body` rule already names it.
        if Some(info.font) != ctx.body.font {
            if let Some(stack) = ctx.font_family_for(info.font) {
                let _ = write!(style, "font-family:{stack};");
            }
        }
        if (info.size.0 - ctx.body.size).abs() >= 0.005 {
            let _ = write!(style, "font-size:{:.4}em;", info.size.0 / ctx.body.size);
        }
    }
    // Non-black only, mirroring the PDF writer's own fill-color 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 any page-absolute box
/// emitter, 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).
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;
    // Built before the wrapper is opened, because whether there is nested
    // flow content decides how the wrapper states its size — see
    // [`wrapper_size`].
    let mut drawing = String::new();
    let mut nested = String::new();
    crate::svg::emit_graphics(
        &mut drawing,
        elems,
        width,
        height,
        depth,
        0.0,
        height,
        &mut |_svg, cbx, _x, _y| emit_nested_text(&mut nested, cbx, ctx),
    );
    let _ = writeln!(
        out,
        "<span class=\"gfx\" style=\"position:relative; display:inline-block; \
         {} vertical-align:{}pt;\">",
        wrapper_size(width, total_h, !nested.is_empty()),
        -depth,
    );
    out.push_str(&drawing);
    out.push_str(&nested);
    out.push_str("</span>\n");
}

/// The `width`/`height` declarations for a math or graphics wrapper.
///
/// The wrapper is an `inline-block` sized to the box's own metrics, and
/// while its only children are the absolutely-positioned `<svg>`s that is
/// exactly right: it reserves the space the layout engine measured, and the
/// measurements say the SVG ink stays inside it to within a device pixel at
/// every zoom and device-scale factor.
///
/// A `draw-text` run breaks that. Its boxes cannot go inside the `<svg>`
/// (see [`emit_nested_text`]), so they end up as FLOW content in the
/// wrapper — and flow content does not make a fixed-size inline-block grow,
/// it overflows, painting over the lines above and below. Measured on
/// `latexcmds`: the `∑` of a `\sum`, which arrives here as a nested run
/// rather than as a `MathGlyph`, hung 6.1pt out of a 10.4pt box. Stating
/// the reserved size as a MINIMUM keeps it as the floor it was always meant
/// to be while letting the box contain whatever the nested content needs —
/// worst overflow across `latexcmds`' 55 wrappers goes from 6.1pt to
/// 0.4pt, which is antialiasing. The alternative, moving the nested content
/// out of the wrapper entirely, was tried and is worse: a `draw-text`
/// operator sits at the box's own origin, which is where in-flow content
/// starts anyway, so today's placement is right for the common leading-
/// operator case and moving it puts `\sum_a^b`'s scripts BEFORE its sigma.
fn wrapper_size(width: f64, total_h: f64, has_flow_content: bool) -> String {
    if has_flow_content {
        format!("min-width:{width}pt; min-height:{total_h}pt;")
    } else {
        format!("width:{width}pt; height:{total_h}pt;")
    }
}

/// 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>`. What this module emits there would be
/// `<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.
///
/// It does stay INSIDE the wrapper `<span>`, and that is what makes the
/// wrapper's own size a MINIMUM rather than a fixed reservation — see
/// [`wrapper_size`], which is where the consequence is worked out.
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 honest approximation instead
/// — 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 PDF
///   writer's own `anchor_y + glyph.dy` arithmetic in its y-up space,
///   `rustyfi-pdf`'s `place_math`), 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.
///
/// **`font-size` is written in USER UNITS, not `pt`, and that is the whole
/// point of [`math_font_size_uu`]** — see that function for why writing the
/// `pt` value with a `pt` suffix inside this viewport magnifies every glyph
/// by exactly 4/3 while leaving `dx`/`dy` and the `rules` paths alone.
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;
    }
    // A math box that draws NOTHING of its own — no glyphs, and every rule
    // a `draw-text` — must not emit the wrapper, for exactly the reason
    // [`emit_graphics_box`] does not: the wrapper would reserve the box's
    // full size a SECOND time, on top of the nested content's own. Both
    // `\paren`-style decorations `latexcmds` builds out of `draw-text` are
    // this shape, and each arrived under a blank rectangle as tall as the
    // equation.
    if glyphs.is_empty() && rules.iter().all(is_pure_text) {
        let mut nested = String::new();
        emit_text_only(&mut nested, rules, ctx);
        out.push_str(&nested);
        return;
    }
    open_opaque(out, ctx);
    let total_h = height + depth;
    // Built before the wrapper is opened, because whether there is nested
    // flow content decides how the wrapper states its size — see
    // [`wrapper_size`].
    let mut drawing = String::new();
    let _ = writeln!(
        drawing,
        "<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}\">",
    );
    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:{};", math_font_size_uu(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 _ = writeln!(
            drawing,
            "<text x=\"{x}\" y=\"{y}\" style=\"{style}\">{}</text>",
            crate::escape_html(&g.text),
        );
    }
    drawing.push_str("</svg>\n");
    let mut nested = String::new();
    if !rules.is_empty() {
        crate::svg::emit_graphics(
            &mut drawing,
            rules,
            width,
            height,
            depth,
            0.0,
            height,
            &mut |_svg, cbx, _x, _y| emit_nested_text(&mut nested, cbx, ctx),
        );
    }
    let _ = writeln!(
        out,
        "<span class=\"math\" style=\"position:relative; display:inline-block; \
         {} vertical-align:{}pt;\">",
        wrapper_size(width, total_h, !nested.is_empty()),
        -depth,
    );
    out.push_str(&drawing);
    out.push_str(&nested);
    out.push_str("</span>\n");
}

/// A math glyph's `pt` font size, spelled for the inside of
/// [`emit_math_svg`]'s viewport — i.e. in SVG USER UNITS, as a `px` length.
///
/// **The bug this exists to prevent.** The math `<svg>` is
/// `width="{w}pt" viewBox="0 0 {w} {h}"`, so one user unit renders as exactly
/// one `pt` — the deliberate "1 user unit = 1 pt" contract `svg.rs`'s module
/// comment states, and what makes `MathGlyph::dx`/`dy` and every `rules` path
/// coordinate emittable as a bare `Length` with no conversion. An ABSOLUTE
/// CSS length inside that viewport does NOT get the same treatment: `pt`
/// resolves against the CSS reference pixel *before* the viewBox transform
/// (SVG fixes 1px = 1 user unit for absolute-unit conversion), so
/// `font-size:12pt` becomes 16 user units and then renders at 16pt. Every
/// glyph came out 4/3 too big while its POSITION stayed right, so glyphs
/// overlapped each other, overflowed the fraction bars and radical overbars
/// (which, being `rules` paths, were correctly scaled), and — because the
/// wrapper `<span>` reserves only the box's own `height`/`depth` while the
/// `<svg>` is `overflow:visible` — spilled ink into the lines above and
/// below. The PDF was never affected: it positions each glyph absolutely and
/// sets the size in the content stream's own points.
///
/// **Why `px` and not a bare number**, which is what "user units" literally
/// means. A unitless length is legal in SVG only as a PRESENTATION
/// ATTRIBUTE (`font-size="12"`); this size goes into `style="…"`, which is
/// CSS, and CSS requires a unit on a non-zero `<length>`. Measured in
/// chromium inside a `viewBox="0 0 100 100"`/`width="100pt"` viewport, four
/// spellings of "12" on the same `<text>`:
///
/// | written                     | computed | user units |
/// |-----------------------------|----------|-----------:|
/// | `style="font-size:12pt"`    | `16px`   |         16 |
/// | `style="font-size:12px"`    | `12px`   |         12 |
/// | `style="font-size:12"`      | `12px`   |         12 |
/// | `font-size="12"` (attribute)| `12px`   |         12 |
///
/// So the bare `style` spelling happens to work in Blink — Blink runs the
/// SVG presentation-attribute grammar over the declaration — but it is
/// invalid CSS and Gecko drops it, which would leave the glyph at the
/// INHERITED body size with no error anywhere. `px` is the portable
/// spelling of one user unit (SVG fixes 1px = 1 user unit), so the number
/// is unchanged and only the unit is corrected: 12pt of document size ->
/// `font-size:12px` -> 12 user units -> 12pt rendered.
///
/// Every other length inside this viewport is already unitless, because
/// every other one is an attribute rather than CSS: `x`/`y` here, and
/// `svg.rs`'s `d`, `stroke-width`, `stroke-dasharray` and
/// `stroke-dashoffset`. `font-size` is the only one that had to be a
/// declaration, which is why it was the only one that got this wrong.
fn math_font_size_uu(size_pt: f64) -> String {
    format!("{size_pt}px")
}