Skip to main content

rustyfi_html/
lib.rs

1//! HTML output backends. There are two, and they answer different
2//! questions.
3//!
4//! **This module is the LAYOUT-FAITHFUL one** (`--format html-fixed`,
5//! [`render_html_fixed`]): it serializes the SAME post-page-break
6//! `Page`/`PlacedLine` model the PDF writer (`rustyfi-pdf`'s `lib.rs`)
7//! consumes — the design doc's "Option A", a non-reflowing "PDF-in-a-div"
8//! twin of the PDF output. Its use is visual diffing: putting this port's
9//! layout in a browser where a run's coordinates can be inspected, rather
10//! than eyeballing two renderings side by side. It is not a web page and
11//! is not meant to be read as one.
12//!
13//! **The [`reflow`] submodule is the readable one** (`--format html`,
14//! [`render_html_reflow`]): one continuous, semantic document with no pages
15//! in it, built from the flat block stream as it stood BEFORE page
16//! breaking. See its own doc comment.
17//!
18//! Everything below concerns the faithful backend.
19//!
20//! **Slice 1** (§Slice 1, "text + block layout of a single-page document"):
21//! `InnerString` runs as positioned `<span>`s, plus the `<div class="page">`
22//! wrapper. **Slice 2** (§Slice 2, "graphics (inline SVG)"), this revision:
23//! `Graphics` as inline SVG (`svg.rs`) and the `Tabular`/`EmbeddedBlock`/
24//! `Frame` composite recursions, mirroring the PDF writer's own `emit_box`
25//! (`lib.rs:646-671`). `Image`/`Math` and `DocExtras::page_graphics` remain
26//! Slice 3+ territory — see `emit_box`'s doc comment below.
27//!
28//! **Slice 3** (§Slice 3, "real fonts + math"): `@font-face` data-URI
29//! embedding (`fonts.rs`) so text/math runs use the SAME TrueType face
30//! the [`rustyfi_pdf::TtfFontStore`] PDF path embeds (metric-faithful
31//! positioning — see this module's `Ctx`/`render_html_fixed_ttf_with`), `Image`
32//! boxes as `<img>` data URIs (`image.rs`, a hand-rolled uncompressed
33//! BMP container — no PNG/image-codec dependency), and `Math` glyphs as
34//! positioned `<span>`s (reusing the same run-emission path as
35//! `InnerString`, per the design doc's math row) with `Math.rules` (the
36//! fraction bar/radical) through the Slice-2 SVG path. The base-14 (no font
37//! store) path is UNCHANGED from Slice 1/2: [`render_html_fixed`] still emits the
38//! generic `.run` CSS default font-family, no `@font-face` block at all.
39//!
40//! **Slice 4** (§Slice 4, "multi-page + print pagination"), this revision:
41//! print pagination CSS (a `@page { size: …; margin: 0 }` rule matching
42//! `geometry.paper_width`/`paper_height`, plus `.page:not(:last-child) {
43//! page-break-after: always; break-after: page }` so a browser print/
44//! print-to-PDF paginates 1:1 with the document — a single-page document has
45//! no non-last `.page`, so this selector matches nothing and its output is
46//! byte-identical to Slice 1-3's, per the design doc's "keep single-page
47//! docs looking identical" requirement) and `DocExtras::page_graphics` (the
48//! per-page deco-graphics underlay this doc comment had flagged as deferred
49//! since Slice 1/2 — see `render_html_impl`'s per-page loop below for the
50//! coordinate-frame reconciliation).
51//!
52//! **Location.** This is its own `rustyfi-html` crate, a peer of
53//! `rustyfi-pdf` (per the design doc's original spec, survey #6). It depends
54//! on `rustyfi-backend` for every box/geometry type used below, plus
55//! `rustyfi-pdf` for [`rustyfi_pdf::TtfFontStore`] (the one type this module
56//! reuses rather than re-implements — only its `pub` `file_index`/
57//! `file_bytes` accessors are used, so this is a plain one-way dependency,
58//! not a cycle: `rustyfi-pdf` does not depend on `rustyfi-html`). Nothing
59//! here touches `pdf_writer` or any other PDF-specific type, only
60//! `rustyfi_backend`/`rustyfi_pdf::TtfFontStore` types and `String`
61//! building.
62
63mod base64;
64mod fonts;
65mod image;
66mod reflow;
67mod svg;
68
69// Reflowable/semantic HTML output mode (`reflow/mod.rs`'s doc comment) —
70// re-exported at the crate root so CLI dispatch calls it exactly like the
71// faithful `render_html_fixed`/`render_html_fixed_ttf_with` pair above (argument-for-
72// argument symmetry, not a new API shape to learn).
73pub use reflow::{
74    render_html_reflow, render_html_reflow_ttf_with, render_html_reflow_ttf_with_decos,
75    render_html_reflow_with_decos,
76};
77
78use std::cell::RefCell;
79use std::collections::BTreeSet;
80use std::fmt::Write as _;
81
82use rustyfi_backend::{
83    place_block_at, Color, DocExtras, FontKey, HorzStringInfo, ImageResource, Length, Page,
84    PageGeometry, PureHorzBox, VertBox,
85};
86
87use rustyfi_pdf::TtfFontStore;
88
89/// Slice 1 never actually constructs this — every text run is valid
90/// UTF-8/HTML-escapable, and no font/image embedding (the error-prone parts,
91/// per the design doc's later slices) happens yet. The `Result` return
92/// shape is kept anyway so `render_html_fixed` is argument-for-argument (module
93/// signature, not module fallibility) with `render_pdf_with`
94/// (`lib.rs:459`), and so Slices 2/3 (SVG graphics, real fonts/`@font-face`,
95/// image data-URIs) can surface a real error without a breaking signature
96/// change.
97#[derive(Debug, thiserror::Error)]
98pub enum HtmlError {
99    #[error(transparent)]
100    Io(#[from] std::io::Error),
101}
102
103/// Shared render-time state threaded through every `emit_*` function below
104/// (Slice 3): the document's image table (so `Image` boxes can resolve an
105/// `ImageId` to its `ImageResource`) and, when rendering under a real
106/// [`TtfFontStore`] (`render_html_fixed_ttf_with`), the store itself plus a
107/// running set of which physical font FILES (`TtfFontStore::file_index`,
108/// not `FontKey` slots — bold/oblique with no configured face dedup to the
109/// regular file exactly like the CID PDF writer's own `FontUsage`,
110/// `cid.rs`) were actually referenced by an emitted run, so the caller only
111/// writes `@font-face` for fonts the document actually used.
112///
113/// `used_fonts` is a `RefCell` rather than threaded as an extra `&mut`
114/// parameter through every recursive call because [`svg::NestedEmitter`]'s
115/// callback type has no `Ctx` slot — every callsite instead closes over
116/// `ctx: &Ctx` by value (a `&Ctx` copy). This module is single-threaded, so
117/// interior mutability here is exactly as safe as (and far less invasive to
118/// plumb than) a threaded `&mut BTreeSet`.
119struct Ctx<'a> {
120    images: &'a [ImageResource],
121    fonts: Option<&'a TtfFontStore>,
122    used_fonts: RefCell<BTreeSet<usize>>,
123}
124
125impl Ctx<'_> {
126    /// Resolve `font`'s CSS `font-family`, marking its backing physical file
127    /// as used so [`fonts::font_face_rules`] later emits an `@font-face` for
128    /// it. Returns `None` in base-14 mode (no store configured) — callers
129    /// then fall back to the `.run` CSS class's generic default (a system
130    /// serif), exactly Slice 1/2's unmodified behavior.
131    fn font_family_for(&self, font: FontKey) -> Option<String> {
132        let store = self.fonts?;
133        let file_idx = store.file_index(font);
134        self.used_fonts.borrow_mut().insert(file_idx);
135        Some(fonts::font_family_name(file_idx))
136    }
137}
138
139/// Serialize typeset pages to a single, self-contained HTML document, using
140/// generic system-font fallback (Slice 1/2 behavior, unchanged): no
141/// `@font-face` block, every run styled by the plain `.run` CSS class. This
142/// is the base-14 twin of [`rustyfi_pdf::render_pdf_with`] — pass
143/// [`render_html_fixed_ttf_with`] a real [`TtfFontStore`] instead when the
144/// document was typeset against real embedded fonts, for metric-faithful
145/// output (Slice 3, see this module's doc comment).
146///
147/// Argument-for-argument with [`rustyfi_pdf::render_pdf_with`] (`lib.rs:459`):
148/// `geometry` (reads only `paper_width`/`paper_height`, exactly like the PDF
149/// writer), `pages` (the post-page-break `Vec<PlacedLine>` per `Page`),
150/// `images` (the document-wide image table — `Image` boxes resolve their
151/// `ImageId` against it, Slice 3), and `extras` (Slice 4: `page_graphics` is
152/// now rendered as a per-page `<svg>` underlay, see `render_html_impl`;
153/// `annotations`/`outline`/`doc_info` remain a documented gap — there is no
154/// HTML analogue of a PDF `/Annots`/`/Outlines` tree in this Option-A
155/// serializer).
156///
157/// One `<div class="page">` per `Page`, sized to `paper_width`/`paper_height`
158/// in CSS `pt` (1:1 with SATySFi's own point unit). Inside, every
159/// `PureHorzBox::InnerString` run on a `PlacedLine` becomes one
160/// absolutely-positioned `<span>` at its resolved `(x, y)` — SATySFi's page
161/// coordinates are already y-**down** from the paper top
162/// (`PlacedLine`'s own doc comment, `pagebreak.rs:13`), which is exactly
163/// CSS's `top` convention, so unlike the PDF writer this needs **no y-flip**.
164pub fn render_html_fixed(
165    geometry: &PageGeometry,
166    pages: &[Page],
167    images: &[ImageResource],
168    extras: &DocExtras,
169) -> Result<String, HtmlError> {
170    render_html_impl(geometry, pages, images, extras, None)
171}
172
173/// Same as [`render_html_fixed`], but rendering under a real [`TtfFontStore`] —
174/// the HTML twin of [`rustyfi_pdf::render_pdf_ttf_with`] (`cid.rs`). Every text
175/// and math run's `<span>` gets an explicit `font-family` naming the
176/// `@font-face` embedding this function adds to the `<style>` block for
177/// every physical font file the document actually referenced, so the
178/// browser lays text out in the SAME face whose metrics the layout was
179/// computed with (the design doc's §Risks "font-metric fidelity" mitigation,
180/// Slice 3's whole point).
181pub fn render_html_fixed_ttf_with(
182    geometry: &PageGeometry,
183    pages: &[Page],
184    store: &TtfFontStore,
185    images: &[ImageResource],
186    extras: &DocExtras,
187) -> Result<String, HtmlError> {
188    render_html_impl(geometry, pages, images, extras, Some(store))
189}
190
191fn render_html_impl(
192    geometry: &PageGeometry,
193    pages: &[Page],
194    images: &[ImageResource],
195    extras: &DocExtras,
196    font_store: Option<&TtfFontStore>,
197) -> Result<String, HtmlError> {
198    let paper_w = geometry.paper_width.0;
199    let paper_h = geometry.paper_height.0;
200
201    let ctx = Ctx {
202        images,
203        fonts: font_store,
204        used_fonts: RefCell::new(BTreeSet::new()),
205    };
206
207    // Pass 1: emit every page's markup, recording (via `ctx.used_fonts`,
208    // Slice 3) which physical font files were actually referenced — mirrors
209    // the CID PDF writer's own two-pass shape (`cid.rs`'s `page_content`
210    // pass populating `usage` before `write_font` runs).
211    let mut body = String::new();
212    for (i, page) in pages.iter().enumerate() {
213        body.push_str(&format!(
214            "<div class=\"page\" style=\"width:{paper_w}pt; height:{paper_h}pt;\">\n"
215        ));
216        // Slice 4: `DocExtras::page_graphics` — one overlay per page (missing
217        // = empty, mirrors `render_pdf_with`'s own `extras.page_graphics
218        // .get(i)...unwrap_or(&[])`, `lib.rs:528`), drawn FIRST so it sits
219        // UNDER the page's text/images (background fills/borders), exactly
220        // `page_content`'s own overlay-first order (`lib.rs:566-577`).
221        //
222        // **Coordinate-frame reconciliation.** Unlike every other
223        // `emit_graphics` call in this module, `page_graphics` elements are
224        // NOT box-local — `fire_hooks` (`rustyfi-lang/src/lib.rs:280`) fills
225        // them in ABSOLUTE PDF y-up page coordinates (`doc.rs:76`, and the
226        // PDF writer feeds them to `place_graphics` at anchor `(0.0, 0.0)`,
227        // `lib.rs:576`, confirming "absolute" — no per-box translate). Reuse
228        // `svg::emit_graphics`'s existing box-local-to-page-space formula
229        // (`page = (tx + px, ty - py)`, that module's doc comment) by
230        // choosing `(tx, ty, height) = (0.0, paper_h, paper_h)`: `ty - py`
231        // becomes exactly the y-flip `paper_h - py` this absolute-coordinate
232        // convention needs (PDF y-up, paper-bottom origin -> CSS y-down,
233        // paper-top origin), and the viewport top `ty - height = 0`/total
234        // height `height + depth = paper_h` cover the full page exactly
235        // (`depth = 0.0`).
236        let overlay = extras
237            .page_graphics
238            .get(i)
239            .map(|v| v.as_slice())
240            .unwrap_or(&[]);
241        svg::emit_graphics(
242            &mut body,
243            overlay,
244            paper_w,
245            paper_h,
246            0.0,
247            0.0,
248            paper_h,
249            &mut |out, cbx, x, y| emit_box(out, cbx, x, y, &ctx),
250        );
251        for line in &page.lines {
252            for (dx, bx) in &line.contents {
253                let tx = (line.x + *dx).0;
254                let ty = line.baseline_y.0;
255                emit_box(&mut body, bx, tx, ty, &ctx);
256            }
257        }
258        body.push_str("</div>\n");
259    }
260
261    let mut out = String::new();
262    out.push_str("<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n");
263    out.push_str("<style>\n");
264    out.push_str("body { margin: 0; padding: 12pt; background: #999; }\n");
265    out.push_str(
266        ".page { position: relative; background: #fff; margin: 0 auto 12pt auto; \
267         overflow: hidden; box-shadow: 0 0 4pt rgba(0,0,0,0.4); }\n",
268    );
269    out.push_str(
270        ".run { position: absolute; margin: 0; padding: 0; white-space: pre; \
271         font-family: serif; line-height: 1; }\n",
272    );
273    // Slice 4: print pagination. `@page` pins the printed sheet size to
274    // EXACTLY the document's own paper size (no browser default-margin
275    // letterhead), and `.page:not(:last-child)` forces a hard page break
276    // after every page except the last — so printing/PDF-from-browser
277    // reproduces the document's own page count 1:1. `:not(:last-child)`
278    // rather than an unconditional rule on every `.page` avoids a trailing
279    // blank page after the final one, and — the design's "keep single-page
280    // docs looking identical" requirement — a document with exactly one
281    // `.page` div has no non-last sibling, so this selector matches nothing
282    // there: single-page output is byte-identical to Slice 1-3's aside from
283    // this now-always-present (but inert) rule text itself. Screen stacking
284    // (the visible gap/border between pages) is unchanged from Slice 1 — the
285    // `.page` margin/box-shadow above already provides it.
286    out.push_str(&format!(
287        "@page {{ size: {paper_w}pt {paper_h}pt; margin: 0; }}\n"
288    ));
289    out.push_str(".page:not(:last-child) { page-break-after: always; break-after: page; }\n");
290    // Slice 3: one `@font-face` per physical font file the document
291    // referenced (empty when `font_store` is `None`, or when a store was
292    // given but nothing was ever emitted through it — e.g. an empty
293    // `pages`), keeping the base-14 path's `<style>` block byte-identical
294    // to Slice 1/2.
295    if let Some(store) = font_store {
296        let used = ctx.used_fonts.borrow();
297        out.push_str(&fonts::font_face_rules(store, &used));
298    }
299    out.push_str("</style>\n</head>\n<body>\n");
300    out.push_str(&body);
301    out.push_str("</body>\n</html>\n");
302    Ok(out)
303}
304
305/// Emit one already-placed `PureHorzBox` at absolute page coordinates
306/// `(tx, ty)` — `tx` the box's left edge, `ty` its **baseline**, both in
307/// SATySFi's own y-down page space (no flip needed for HTML/CSS, unlike the
308/// PDF writer's `emit_box`, `lib.rs:604`, which this mirrors in shape).
309///
310/// Slice 1 handled only `InnerString` (a positioned `<span>`, via
311/// [`emit_run`]) and `OuterEmpty`/`FixedEmpty` (inter-word glue/skips —
312/// already fully accounted for by the caller's `dx` offsets, so they render
313/// nothing extra). Slice 2 added `Graphics` (inline SVG, via
314/// [`svg::emit_graphics`]) and the three composite recursions the PDF
315/// writer's own `emit_box` has (`Tabular`/`EmbeddedBlock`/`Frame`,
316/// `lib.rs:646-671`) so nested content inside them renders too. Slice 3
317/// adds `Image` (an `<img>` data URI, via [`image::data_uri`]) and `Math`
318/// (per-glyph `<span>`s through the same [`emit_run`] path, plus `rules`
319/// through [`svg::emit_graphics`] — see the design doc's math row: the
320/// semantic tree is already flattened by `read_math` by the time a box
321/// exists, so this needs no math-specific rendering beyond reusing the
322/// text/SVG paths). The remaining zero-width markers still hit the wildcard
323/// arm — exactly `emit_box`'s own `_ => {}` (`lib.rs:672`).
324fn emit_box(out: &mut String, bx: &PureHorzBox, tx: f64, ty: f64, ctx: &Ctx) {
325    match bx {
326        PureHorzBox::InnerString {
327            info, text, height, ..
328        } => {
329            // `info.rising` raises the run (a positive rising moves it UP
330            // the page, i.e. DECREASES the y-down `ty` — the mirror image
331            // of the PDF writer's `ty + rising` in its y-**up** space,
332            // `lib.rs:614`). `height` is the run's ascent (height above its
333            // own baseline, `hbox.rs:88`), so the span's CSS `top` (its
334            // TOP edge) is the effective baseline minus that ascent.
335            let baseline = ty - info.rising.0;
336            let top = baseline - height.0;
337            emit_run(out, info, text, tx, top, ctx);
338        }
339        PureHorzBox::OuterEmpty { .. } | PureHorzBox::FixedEmpty { .. } => {
340            // Interword glue / a fixed skip: no visible content of its own —
341            // its width already went into every LATER box's `dx` on this
342            // line (the same reasoning as the PDF writer, which also emits
343            // nothing for these two, `lib.rs:670`'s wildcard).
344        }
345        // §Slice 3 (`Image` sub-step): an `<img>` sized/positioned exactly
346        // like the PDF writer's `place_image` (`lib.rs:165`) — `ty` is the
347        // box's BASELINE, and an `Image` box is all height/zero depth (it
348        // sits entirely above the baseline, `linebreak.rs`'s
349        // `layout_line`), so the baseline IS the image's bottom edge and
350        // `top = ty - height` is its top edge, the same "baseline minus
351        // ascent" arithmetic every other box here uses. Silently skips an
352        // out-of-range `ImageId` (mirrors `write_image_xobjects`'s own
353        // graceful skip, `lib.rs:136-142` — should not happen, but a page
354        // missing one image beats a panic).
355        PureHorzBox::Image {
356            width,
357            height,
358            image,
359        } => {
360            if let Some(res) = ctx.images.get(image.0) {
361                let top = ty - height.0;
362                let w = width.0;
363                let h = height.0;
364                if res.pdf.is_some() {
365                    // `load-pdf-image`: a raster `<img>`/BMP data URI has no
366                    // samples to encode for an imported PDF page
367                    // (`res.samples` is empty). A faithful HTML rendering
368                    // would need to rasterize the page — out of scope here —
369                    // so this emits a bordered placeholder box at the box's
370                    // resolved dimensions instead of silently producing a
371                    // degenerate 0x0 image.
372                    let _ = write!(
373                        out,
374                        "<div style=\"position:absolute; left:{tx}pt; top:{top}pt; \
375                         width:{w}pt; height:{h}pt; box-sizing:border-box; \
376                         border:1px solid #888;\" title=\"PDF page image\"></div>\n",
377                    );
378                } else {
379                    let uri = image::data_uri(res);
380                    let _ = write!(
381                        out,
382                        "<img style=\"position:absolute; left:{tx}pt; top:{top}pt; \
383                         width:{w}pt; height:{h}pt;\" src=\"{uri}\" alt=\"\">\n",
384                    );
385                }
386            }
387        }
388        // §Slice 2: a box carrying resolved `graphics` elements — one
389        // inline `<svg>`, sized/positioned from this box's own outer
390        // metrics (see `svg::emit_graphics`'s doc comment for the
391        // coordinate-frame reconciliation). `GraphicsElem::Text` (a
392        // `draw-text` run) re-enters `emit_box` itself via the callback.
393        PureHorzBox::Graphics {
394            width,
395            height,
396            depth,
397            elems,
398            origin_independent: _,
399        } => {
400            svg::emit_graphics(
401                out,
402                elems,
403                width.0,
404                height.0,
405                depth.0,
406                tx,
407                ty,
408                &mut |out, cbx, x, y| emit_box(out, cbx, x, y, ctx),
409            );
410        }
411        // §Slice 3 (`Math` row): each already-positioned `MathGlyph` is
412        // rendered through the SAME run path as `InnerString` (a `<span>`,
413        // via `emit_run`) — `glyph.dx`/`dy` are box-local offsets from this
414        // box's own placed anchor `(tx, ty)`, y-**up** (mirroring
415        // `place_math`'s `anchor_y + glyph.dy` in PDF's y-up space,
416        // `lib.rs:187-208`), so both `dy` and `info.rising` SUBTRACT from
417        // the page-down `ty` here — the same sign flip `InnerString`'s own
418        // `rising` handling uses above. `glyph.gid` (a raw MATH-table
419        // variant glyph id, not necessarily reachable from `glyph.text` via
420        // cmap — §B3) has no HTML/CSS analogue (there is no way to address a
421        // bare glyph id from markup), so this renders `glyph.text`
422        // regardless — a documented, Option-A-inherent approximation for
423        // that one construction (stretchy delimiters/big operators), not a
424        // regression for the overwhelmingly common cmap-reachable glyph
425        // case. `rules` (the fraction bar/radical) are `GraphicsElem`s in
426        // the SAME box-local convention as `Tabular.rules`, so they route
427        // through the identical `(tx, ty)` anchor via `svg::emit_graphics`.
428        PureHorzBox::Math {
429            width,
430            height,
431            depth,
432            glyphs,
433            rules,
434        } => {
435            for g in glyphs {
436                let baseline = ty - g.dy.0 - g.info.rising.0;
437                let top = baseline - g.height.0;
438                let x = tx + g.dx.0;
439                emit_run(out, &g.info, &g.text, x, top, ctx);
440            }
441            svg::emit_graphics(
442                out,
443                rules,
444                width.0,
445                height.0,
446                depth.0,
447                tx,
448                ty,
449                &mut |out, cbx, x, y| emit_box(out, cbx, x, y, ctx),
450            );
451        }
452        // §Slice 2 (mirrors `emit_box`'s `Tabular` arm, `lib.rs:646-658`):
453        // each cell's already-laid-out boxes at their resolved offset —
454        // `cell.x`/`cdx` are page-down-frame-agnostic horizontal offsets
455        // (added straight through, like every other horizontal offset),
456        // but `cell.baseline_y` is box-local y-**up** from the tabular
457        // box's own baseline-left origin (`TabularCellBox`'s doc comment,
458        // `tabular.rs:60`) — exactly `GraphicsElem`'s convention — so it
459        // SUBTRACTS from the page-down `ty` (the mirror image of the PDF
460        // writer's `ty + cell.baseline_y` in its y-up space). `tab.rules`
461        // are `GraphicsElem`s in that SAME box-local convention, so they
462        // route through the identical `(tx, ty)` anchor via
463        // `svg::emit_graphics`, just like a standalone `Graphics` box.
464        PureHorzBox::Tabular(tab) => {
465            for cell in &tab.cells {
466                for (cdx, cbx) in &cell.contents {
467                    emit_box(
468                        out,
469                        cbx,
470                        tx + (cell.x + *cdx).0,
471                        ty - cell.baseline_y.0,
472                        ctx,
473                    );
474                }
475            }
476            svg::emit_graphics(
477                out,
478                &tab.rules,
479                tab.width.0,
480                tab.height.0,
481                tab.depth.0,
482                tx,
483                ty,
484                &mut |out, cbx, x, y| emit_box(out, cbx, x, y, ctx),
485            );
486        }
487        // §Slice 2 (mirrors `emit_box`'s `EmbeddedBlock` arm, `lib.rs:659-
488        // 661`, via `place_embedded_block`'s HTML twin below): stack the
489        // block's already-broken lines from the box's placed anchor.
490        PureHorzBox::EmbeddedBlock { block, .. } => {
491            emit_embedded_block(out, block, tx, ty, ctx);
492        }
493        // §Slice 2 (mirrors `emit_box`'s `Frame` arm, `lib.rs:667-671`): an
494        // inline frame's contents, all on the frame's OWN baseline (only
495        // `dx` varies per child — no y offset, unlike `Tabular`'s cells).
496        // The frame's deco graphics are NOT emitted here, same as the PDF
497        // writer — they are fired lang-side into `DocExtras::page_graphics`,
498        // a page-level underlay rendered once per page by `render_html_impl`
499        // (Slice 4), not per-`Frame`-box here — same split the PDF writer
500        // itself has (`page_content`'s overlay vs. `emit_box`'s per-box
501        // arms, `lib.rs:566-671`).
502        PureHorzBox::Frame { contents, .. } => {
503            for (dx, cbx) in contents {
504                emit_box(out, cbx, tx + dx.0, ty, ctx);
505            }
506        }
507        _ => {}
508    }
509}
510
511/// Stack an `EmbeddedBlock`'s already-broken `block` lines from its placed
512/// anchor `(tx, ty)`, the HTML twin of `rustyfi-pdf`'s `place_embedded_block`
513/// (`lib.rs:226`). **Sign differs from the PDF version on purpose**: a
514/// `VertBox`/`PlacedLine`'s own `baseline_y` grows DOWNWARD (the same
515/// page-down convention this whole module uses, `pagebreak.rs:13`), so here
516/// each later line's growing `baseline_y` delta is ADDED to the page-down
517/// `ty` (moving further down the page) — the PDF version SUBTRACTS the same
518/// delta because ITS `ty` lives in PDF's y-**up** space, where "further
519/// down" means a smaller value.
520fn emit_embedded_block(out: &mut String, block: &[VertBox], tx: f64, ty: f64, ctx: &Ctx) {
521    let placed = place_block_at((Length::ZERO, Length::ZERO), block.to_vec());
522    let Some(first) = placed.first() else {
523        return;
524    };
525    let first_offset = first.baseline_y;
526    for line in &placed {
527        let y = ty + (line.baseline_y - first_offset).0;
528        for (dx, cbx) in &line.contents {
529            emit_box(out, cbx, tx + (line.x + *dx).0, y, ctx);
530        }
531    }
532}
533
534/// One `InnerString`/`Math`-glyph-shaped run: an absolutely-positioned
535/// `<span>` at its top-left corner `(tx, top)`, sized in CSS `pt` (1:1 with
536/// SATySFi points) via `info.size`, with `text` HTML-escaped. Slice 3: when
537/// `ctx` carries a real [`TtfFontStore`] (`render_html_fixed_ttf_with`), the span
538/// gets an explicit inline `font-family` naming the `@font-face` this
539/// document's `<style>` block embeds for `info.font`'s backing file
540/// (`Ctx::font_family_for`, which also records the file as used); in
541/// base-14 mode (`ctx.fonts` is `None`) this stays Slice 1's behavior
542/// exactly — no inline `font-family`, so the `.run` CSS class's generic
543/// system serif default applies.
544fn emit_run(out: &mut String, info: &HorzStringInfo, text: &str, tx: f64, top: f64, ctx: &Ctx) {
545    let size = info.size.0;
546    let family_style = match ctx.font_family_for(info.font) {
547        Some(family) => format!(" font-family:\"{family}\";"),
548        None => String::new(),
549    };
550    // Non-black only, mirroring both PDF writers' `q…Q`-scoped fill-color
551    // guard, so an all-black document's HTML output is unchanged.
552    let color_style = if info.color != Color::Gray(0.0) {
553        format!(" color:{};", svg::css_color(info.color))
554    } else {
555        String::new()
556    };
557    let _ = write!(
558        out,
559        "<span class=\"run\" style=\"left:{tx}pt; top:{top}pt; font-size:{size}pt;{family_style}{color_style}\">{}</span>\n",
560        escape_html(text),
561    );
562}
563
564/// Escape the five HTML/attribute-hostile characters. Slice 1's `<span>`
565/// text is never re-parsed as markup, so this is the standard minimal set
566/// (no need for a full entity table).
567fn escape_html(s: &str) -> String {
568    let mut out = String::with_capacity(s.len());
569    for c in s.chars() {
570        match c {
571            '&' => out.push_str("&amp;"),
572            '<' => out.push_str("&lt;"),
573            '>' => out.push_str("&gt;"),
574            '"' => out.push_str("&quot;"),
575            '\'' => out.push_str("&#39;"),
576            _ => out.push(c),
577        }
578    }
579    out
580}