Skip to main content

rustyfi_html/reflow/
mod.rs

1//! Reflowable/semantic HTML — what `--format html` produces.
2//!
3//! Alongside the FAITHFUL twin
4//! ([`crate::render_html_fixed`]/[`crate::render_html_fixed_ttf_with`],
5//! `--format html-fixed`, which serializes the same post-page-break
6//! placed-box model the PDF writer consumes, one absolutely-positioned
7//! `<span>` per glyph run), this mode branches at the pre-page-break flat
8//! `Vec<VertBox>` (`DocumentValue::reflow_source` in `rustyfi-lang`, the
9//! design doc's "Option B") and emits REAL flowing HTML.
10//!
11//! **There are no pages here.** Reading the stream before page breaking is
12//! what makes that true rather than merely stitched-together: nothing is cut
13//! at a page boundary, and the page furniture — running headers, footers,
14//! folios — is generated during page breaking and so never exists at all.
15//! The output is one continuous document the browser re-breaks, hyphenates
16//! and justifies at whatever width it is read.
17//!
18//! **No `position`/`top`/`left` anywhere in this module's own output**, the
19//! defining difference from the faithful twin. The one exception is
20//! deliberate and is not page positioning: math and graphics are DRAWINGS,
21//! and each is an intrinsically-sized inline `<svg>` whose own contents are
22//! positioned within its own tiny viewport (see `inline.rs`'s
23//! `emit_math_svg`/`emit_graphics_box`).
24//!
25//! Three concerns big enough to have their own explanations:
26//!
27//! - **what a glue box becomes**, and why "glue means space" made Japanese
28//!   unreadable — `text.rs`'s doc comment;
29//! - **which runs need a `<span>` at all** — also `text.rs`; the document's
30//!   dominant `(font, size)` goes on `body` so the bulk of the prose is
31//!   written as bare text;
32//! - **where a footnote goes** when there is no page foot — `inline.rs`'s
33//!   `Footnote` arm and `block.rs`'s `drain_footnotes`. It becomes an
34//!   `<aside>` immediately after the paragraph that referenced it, which is
35//!   where a reader wants it in a continuous document; the in-text anchor is
36//!   a zero-width link target, because the document has already typeset its
37//!   own reference marker.
38//!
39//! **Slice 1 scope** (see the design doc §6): paragraphs (`Line`-runs
40//! coalesced by `Skip`/frame boundaries), inline text (`InnerString`,
41//! escaped + styled by font/size/color/rising), block nesting
42//! (`FrameStart`/`FrameEnd`, `EmbeddedBlock`), and a clean semantic
43//! stylesheet. Math/graphics/images/tables/footnotes were rendered as inert
44//! placeholder `<span>`s.
45//!
46//! **Slice 2 scope** (design doc §6 "S2"): `Math`/`Graphics` render as real
47//! inline `<svg>` (reusing [`crate::svg::emit_graphics`] verbatim for
48//! graphics content, §4's "reuse verbatim"), and `\href`-style links
49//! (`annot.satyh`'s `register-link-to-uri`/`-to-location`, fired from a
50//! `PureHorzBox::Frame`'s deco) become real `<a href>` elements — see
51//! `Ctx::links`'s doc comment for HOW a page-absolute `Annot` gets matched
52//! back to a specific pre-page-break `Frame` (the `DecoId` both carry, not
53//! a geometry guess). `Image` and `Footnote` were placeholders through
54//! Slice 4 and are now real; see this module's doc comment above.
55//!
56//! **Slice 3 scope** (design doc §6 "S3", the "above-flat structure" slice
57//! — see `reflow/structure.rs` for the implementation and its own doc
58//! comment on exactly what is/isn't recoverable):
59//! - `extras.outline` → BEST-EFFORT promotion of the matching in-flow
60//!   paragraph to `<h1>`..`<h6>` (`structure::find_heading_level`,
61//!   `block.rs`'s `Para::heading_level`) — correlated to the outline entry
62//!   by `dest_name`, the SAME string both `register-outline` and
63//!   `register-location-frame`/`register-destination` resolve a label
64//!   through (`Interp::dest_name`), so this is a structural match via the
65//!   existing `Ctx::dests` `DecoId` map, not a text/geometry heuristic.
66//! - `PureHorzBox::Tabular` now renders as a real `<table>`/`<tr>`/`<td>`
67//!   (`structure::render_table`), replacing the Slice 1/2 `table-placeholder`
68//!   `<span>`.
69//! - List structure (`itemize`/`enumerate`) is NOT promoted to `<ul>`/`<ol>`
70//!   here — see `structure.rs`'s doc comment for why it was judged not
71//!   reliably recoverable from the box tree, unlike outline/tabular. (S4,
72//!   below, resolves this with a new lever.)
73//!
74//! **Slice 4 scope**: the box tree genuinely has no recoverable
75//! list/emphasis structure (S3's verdict above), so S4 adds a NEW lever —
76//! inert marker boxes (`VertBox::ListMark`/`PureHorzBox::InlineMark`)
77//! emitted positionally by a modified `itemize.satyh` (list/item boundaries,
78//! ordered-vs-unordered) and by the repo-controlled `\emph`/`\bold`
79//! (`v01-mini.satyh`, `std-ja.satyh`) — rather than trying to infer
80//! structure from the existing flat stream. BOTH generations' `itemize`
81//! now emit them (`dist/packages/itemize.satyh` as well as
82//! `dist-v01/`'s), so an ordinary 0.0.6 `+listing`/`+enumerate` gets a real
83//! `<ul>`/`<ol>` too; a third-party list package (the corpus `enumitem`)
84//! does not, and degrades to its own drawn bullets in flat paragraphs.
85//! - `block.rs`'s `walk_vboxes` gains a `VertBox::ListMark` arm: a small
86//!   stack of open `<ul>`/`<ol>` tags makes nesting fall out automatically
87//!   from how the markers are nested in the box stream (no depth payload
88//!   needed).
89//! - `inline.rs`'s `emit_inline` gains a `PureHorzBox::InlineMark` arm: an
90//!   `<em>`/`<strong>` tag stack (`Ctx::emph_stack`) and a bullet-suppression
91//!   counter (`Ctx::bullet_suppress`) that drops the drawn bullet/number
92//!   glyph run between a `BulletStart`/`BulletEnd` fence.
93//! - The markers are proven INERT for PDF/faithful HTML (design doc §4.3):
94//!   `chop_page`/`place_block_at`/`measure_block` (rustyfi-backend) skip
95//!   `VertBox::ListMark` with zero contribution before it can ever reach a
96//!   `PlacedLine`, and `PureHorzBox::InlineMark` contributes zero advance
97//!   everywhere it's measured and renders nothing (both writers' wildcard
98//!   `emit_box` arm) wherever it still rides inside a placed line's
99//!   `contents` — so this module is the ONLY consumer.
100//! - Emphasis is opt-in and per-command (§5's honesty verdict): only
101//!   `v01-mini.satyh`'s/`std-ja.satyh`'s `\emph`/`\bold` are wired: a
102//!   third-party or `md-ja.satyh` `\emph` degrades to today's plain text,
103//!   by design (never a font/size/color heuristic).
104//!
105//! **Additivity** (design doc §8): this module is reached only through the
106//! two `pub fn`s below, themselves reached only via the CLI's
107//! `--format html` (`rustyfi`). Nothing here changes the
108//! behavior of [`crate::render_html_fixed`]/[`crate::render_html_fixed_ttf_with`] or
109//! `rustyfi_pdf::render_pdf*` — it only reuses their already-`pub(super)`
110//! (crate-visible) helpers ([`crate::escape_html`], [`crate::svg::css_color`],
111//! [`crate::svg::emit_graphics`], [`crate::fonts`], [`crate::image::data_uri`])
112//! read-only.
113
114mod block;
115mod css;
116mod inline;
117mod structure;
118mod text;
119
120use std::cell::{Cell, RefCell};
121use std::collections::{BTreeSet, HashMap};
122use std::fmt::Write as _;
123
124use rustyfi_backend::{
125    AnnotAction, DecoId, DocExtras, FontKey, FrameDecoration, GraphicsElem, ImageResource,
126    PageGeometry, VertBox,
127};
128
129use rustyfi_pdf::TtfFontStore;
130
131use crate::HtmlError;
132
133pub(crate) use text::BodyStyle;
134
135/// Render-time state shared by every `emit_*` function in this module — the
136/// reflow twin of `crate::Ctx` (kept as a separate type rather than reused
137/// directly: this mode has no `images` byte-serving need yet, S1 renders
138/// `Image` as a placeholder, see `inline.rs`; keeping a distinct type avoids
139/// coupling the two modes' evolution).
140pub(crate) struct Ctx<'a> {
141    pub(crate) fonts: Option<&'a TtfFontStore>,
142    pub(crate) used_fonts: RefCell<BTreeSet<usize>>,
143    /// S2 ("Links/metadata"): `DecoId -> action` for every
144    /// `register-link-to-uri`/`-to-location` call the compile driver
145    /// observed firing (`DocumentValue:: reflow_links`) — built once per
146    /// render from the flat slice passed in, so `inline::emit_inline`'s
147    /// `Frame` arm can look up "is THIS Frame's deco a link" in O(1) by the
148    /// exact same `DecoId` the Frame box itself carries (a structural match,
149    /// not a geometry guess — see that field's doc comment on
150    /// `rustyfi_lang::value::DocumentValue`).
151    pub(crate) links: HashMap<DecoId, &'a AnnotAction>,
152    /// Same idea as `links`, for `register-destination`
153    /// (`DocumentValue::reflow_dests`) — `DecoId -> the named-destination
154    /// key`, consulted by `block::walk_vboxes`'s `FrameStart`/`FrameEnd` arm
155    /// and `inline::emit_inline`'s `Frame` arm to place an `id="…"` anchor.
156    pub(crate) dests: HashMap<DecoId, &'a str>,
157    /// S3 (design doc §6 "S3" / this module's doc comment): `dest_name ->
158    /// outline level`, built once per render from `extras.outline`
159    /// (`DocExtras::outline`) — consulted by `structure::find_heading_level`
160    /// to promote the paragraph whose `Frame` `DecoId` resolves (via
161    /// `dests`, above) to a `register-outline`-registered destination name.
162    /// Owned (`String`, not `&'a str`) rather than borrowed from `extras`:
163    /// keeps `Ctx`'s lifetime parameter tied only to the `links`/`dests`
164    /// slices it already had, avoiding a second lifetime bound on `extras`.
165    pub(crate) outline_by_dest: HashMap<String, i64>,
166    /// S4 ("Inline level"): the stack of currently-open `<em>`/`<strong>`
167    /// spans, keyed by their `InlineMarkKind::EmphStart::strong` bit —
168    /// `EmphEnd` carries no payload of its own, so the matching open tag
169    /// has to be remembered somewhere; `RefCell` (not a threaded `&mut`)
170    /// mirrors `used_fonts` above, keeping `inline::emit_inline`'s
171    /// `&Ctx`-only signature (no caller needs to change to thread a stack
172    /// through).
173    pub(crate) emph_stack: RefCell<Vec<bool>>,
174    /// S4 (design doc §4.1 "BulletStart/End fence"): a nesting counter
175    /// (not a bare flag — `BulletStart`/`BulletEnd` pairs are never nested
176    /// in practice, but a counter is exactly as cheap and can't go
177    /// negative-then-wrong on a stray unmatched marker) that, while
178    /// non-zero, makes `inline::emit_inline` render nothing for any box
179    /// OTHER than an `InlineMark` itself — the drawn bullet/number glyph
180    /// run between the fence is dropped, since the real `<ul>`/`<ol>`
181    /// marker replaces it (R2, design doc §6.4).
182    pub(crate) bullet_suppress: RefCell<u32>,
183    /// The stack of wrappers opened by an `InlineFrameMarker` start and not
184    /// yet closed, as `(tag to RE-open it with, tag to close it with)`. The
185    /// end marker carries only `end: true` — it does not say whether the
186    /// start opened an `<a>` or a `<span>` — so, exactly like `emph_stack`
187    /// above, the matching closer has to be remembered rather than
188    /// recomputed.
189    ///
190    /// The re-open tag exists because an `inline-frame-breakable` region can
191    /// straddle a paragraph boundary: `\ref`-style markup opens its wrapper
192    /// on one `Line` and closes it after a `Skip` has already flushed the
193    /// paragraph, which would otherwise leave `<span class="iframe">` open
194    /// across `</p>`. `block.rs` closes every open wrapper when it flushes
195    /// and re-opens them on the next paragraph's first content — the same
196    /// repair an HTML parser performs for a misnested inline element. It is
197    /// a RE-open rather than the original tag because a wrapper carrying an
198    /// `id=` must not repeat it; only the first fragment is the anchor.
199    pub(crate) iframe_stack: RefCell<Vec<(String, &'static str)>>,
200    /// The document's image table, so an `Image` box can resolve its
201    /// `ImageId` to an `ImageResource` and become a real `<img>` data URI
202    /// (`crate::image::data_uri`, shared verbatim with the faithful
203    /// backend). Slices 1-4 rendered an inert placeholder here; a document
204    /// like `figbox`'s manual is 39 figures, so the placeholder was most of
205    /// what the document is about.
206    pub(crate) images: &'a [ImageResource],
207    /// The `(font, size)` pair most of the document's characters are set in
208    /// — see [`BodyStyle`]. `css.rs` puts it on `body`; `inline.rs` omits it
209    /// from every run that matches, and most runs do.
210    pub(crate) body: BodyStyle,
211    /// The natural width (pt) of glue seen since the last thing that was
212    /// actually written, awaiting the character that follows it before
213    /// `text::wants_space` can judge whether it is a space, a kern, or a
214    /// bare break opportunity. Consecutive glues merge by taking the widest
215    /// — two adjacent glues are still at most one space.
216    pub(crate) pending_glue: Cell<Option<f64>>,
217    /// The last character actually written into the flow, the `prev` half of
218    /// [`text::wants_space`]'s decision. Deliberately NOT reset by the
219    /// transparent wrappers (`Frame`, `InlineFrameMarker`, `InlineMark`), so
220    /// a CJK/CJK pair straddling a `\ref`'s `<a>` still suppresses its
221    /// space; reset to `None` by opaque boxes (`<svg>`, `<img>`, `<table>`),
222    /// which have no last character to speak of.
223    pub(crate) last_char: Cell<Option<char>>,
224    /// Whether the last text run written was set in a fixed-pitch face.
225    ///
226    /// This is the only signal in the box stream that distinguishes a line
227    /// boundary the browser should REDO from one it must KEEP. Both arrive as
228    /// two consecutive `VertBox::Line`s with nothing between them: a wrapped
229    /// paragraph and a `+code` block are structurally identical, because
230    /// `code.satyh` calls `line-break` once per source line exactly as the
231    /// line breaker does per wrapped line. Reset to `false` by any
232    /// proportional run, so it means "still inside monospace text".
233    pub(crate) mono_run: Cell<bool>,
234    /// Rules belonging to a table whose own `TabularBox` does not carry them,
235    /// as `(width, height, rules)`.
236    ///
237    /// `easytable` draws a table as TWO overlaid `tabular`s at one anchor:
238    /// one holds the rules over PHANTOM cells, the other the real content and
239    /// no rules at all (its own source shows the shape plainly — `ib-rule`
240    /// and `ib-table`, both `draw-text` into one `inline-graphics`). Rendered
241    /// independently, the rules land on a table with nothing in it — dropped
242    /// as empty — and the visible table comes out with no rules. Pushed by
243    /// `inline.rs`'s text-only graphics path, which is the only place the two
244    /// halves are visible together, and matched back by geometry.
245    pub(crate) tabular_rules: RefCell<Vec<(f64, f64, Vec<GraphicsElem>)>>,
246    /// `DecoId -> the frame's own decoration`, from
247    /// `DocumentValue::reflow_frame_decos`.
248    ///
249    /// A block frame's decoration is a lang-side callback, and this backend
250    /// has no page grid to run it on — which is why `.frame` drew nothing at
251    /// all, and every `stdjabook` title block, `+code` panel and framed
252    /// figure arrived as bare text. `fire_hooks` already runs the callback
253    /// for the PDF path; this is the same graphics, recorded box-local at the
254    /// frame's natural size so it can be SCALED to whatever width the reader
255    /// gives it rather than replayed at a fixed one.
256    pub(crate) frame_decos: HashMap<DecoId, &'a FrameDecoration>,
257    /// Footnote bodies whose reference marker has been emitted but whose
258    /// text has not yet been placed. `block.rs`'s `flush_para` drains this
259    /// immediately after closing the referencing paragraph — see this
260    /// crate's `reflow` module doc comment on why "just after the
261    /// paragraph" is where a footnote belongs once there is no page foot to
262    /// put it at.
263    pub(crate) footnotes: RefCell<Vec<(usize, String)>>,
264    /// Monotonic footnote number, shared by the `<sup>` reference and the
265    /// `<aside>` body so the two can link to each other.
266    pub(crate) footnote_seq: Cell<usize>,
267    /// Canonical `ImageId`s of images placed more than once, in first-use
268    /// order. Their bytes go into the stylesheet ONCE, as a
269    /// `background-image` rule (`css.rs`'s `shared_image_rules`), instead of
270    /// once per placement. See [`Ctx::image_sharing`].
271    pub(crate) shared_images: RefCell<Vec<usize>>,
272    /// Every `ImageId` mapped to the LOWEST `ImageId` holding identical
273    /// pixels, and how many placements that canonical image has in total.
274    ///
275    /// Content, not identity, is what has to be deduplicated: each
276    /// `include-image` call mints a fresh `ImageResource` even for a file
277    /// already loaded, so `figbox`'s manual holds seventeen distinct
278    /// `ImageId`s covering two actual pictures. Keying on the id alone found
279    /// nothing to share.
280    image_canon: HashMap<usize, (usize, usize)>,
281    /// The `style` of the `<span class="run">` currently left OPEN, if any.
282    /// A run whose style matches simply appends its text to it, so a word
283    /// the box stream split into chunks — and a Japanese phrase it split
284    /// into individual characters, which is every CJK run at any size other
285    /// than the body's — comes out as ONE span of ordinary text rather than
286    /// one span per chunk. Every emitter that writes something which is not
287    /// part of the run (a tag, a strut, an `<svg>`) closes it first via
288    /// `inline::close_run`; a space and a soft hyphen deliberately do not,
289    /// since neither carries style and both belong inside the word.
290    pub(crate) open_run: RefCell<Option<String>>,
291}
292
293impl Ctx<'_> {
294    /// Resolve `font` to a CSS `font-family` VALUE — the real family name
295    /// the font file declares, followed by generic fallbacks
296    /// (`fonts::reflow_font_stack`). `None` in base-14 mode, and for a file
297    /// whose `name` table declares no usable family, in which case the
298    /// stylesheet's own stack applies.
299    ///
300    /// Unlike the faithful backend's namesake this NAMES rather than
301    /// embeds — see `fonts::reflow_font_stack` for the argument. The
302    /// `used_fonts` bookkeeping is kept anyway: it costs nothing and keeps
303    /// the two backends' shape aligned should a subsetting embedder ever
304    /// make embedding affordable here.
305    pub(crate) fn font_family_for(&self, font: FontKey) -> Option<String> {
306        let store = self.fonts?;
307        let file_idx = store.file_index(font);
308        self.used_fonts.borrow_mut().insert(file_idx);
309        let family = store.file_family_name(file_idx)?;
310        Some(crate::fonts::reflow_font_stack(&family))
311    }
312
313    /// Whether `font` is a fixed-pitch face. Unlike [`Ctx::font_family_for`]
314    /// this does NOT mark the file used: asking what a face IS must not pull
315    /// it into the document's font set.
316    pub(crate) fn is_monospace(&self, font: Option<FontKey>) -> bool {
317        let (Some(store), Some(font)) = (self.fonts, font) else {
318            return false;
319        };
320        store
321            .file_family_name(store.file_index(font))
322            .is_some_and(|f| crate::fonts::is_monospace_family(&f))
323    }
324
325    /// Record that a glue box of `natural_pt` natural width stands here.
326    /// Nothing is written yet: whether it becomes a space depends on the
327    /// character that follows (`text::wants_space`), which is not known
328    /// until the next run arrives.
329    pub(crate) fn note_glue(&self, natural_pt: f64) {
330        let merged = match self.pending_glue.get() {
331            Some(prev) if prev >= natural_pt => prev,
332            _ => natural_pt,
333        };
334        self.pending_glue.set(Some(merged));
335    }
336
337    /// Resolve the pending glue against the character about to be written
338    /// (`next`, `None` before an opaque box or at a paragraph edge),
339    /// appending a space to `out` if one is warranted.
340    pub(crate) fn resolve_glue(&self, out: &mut String, next: Option<char>) {
341        if let Some(width) = self.pending_glue.take() {
342            if text::wants_space(self.last_char.get(), next, width) {
343                out.push(' ');
344            }
345        }
346    }
347
348    /// Drop any pending glue and forget the last character — used at a hard
349    /// boundary (a new paragraph, a table cell, a footnote body) where a
350    /// space carried over from the previous context would be wrong.
351    pub(crate) fn reset_flow(&self) {
352        self.pending_glue.set(None);
353        self.last_char.set(None);
354    }
355
356    /// For an `ImageId`: the canonical id of the image it holds, and whether
357    /// that image is placed more than once (and so should be shared through
358    /// the stylesheet rather than repeated inline). See `image_canon`.
359    pub(crate) fn image_sharing(&self, id: usize) -> (usize, bool) {
360        match self.image_canon.get(&id) {
361            Some(&(canon, uses)) => (canon, uses > 1),
362            None => (id, false),
363        }
364    }
365}
366
367/// Group `images` by CONTENT and fold in each group's total placement count
368/// from the pre-pass, producing `Ctx::image_canon`. Two resources are the
369/// same picture when their pixel dimensions and their bytes agree — the
370/// original JPEG stream when there is one (which is also what
371/// `image::data_uri` will emit), the decoded samples otherwise.
372fn canonical_images(
373    images: &[ImageResource],
374    uses: &HashMap<usize, usize>,
375) -> HashMap<usize, (usize, usize)> {
376    let mut first_by_content: HashMap<(&[u8], u32, u32), usize> = HashMap::new();
377    let mut canon_of: HashMap<usize, usize> = HashMap::new();
378    for (idx, res) in images.iter().enumerate() {
379        let bytes: &[u8] = match &res.jpeg_dct {
380            Some(j) => &j.bytes,
381            None => &res.samples,
382        };
383        // An imported PDF page has neither, so every one of them would hash
384        // alike; they render as a labelled box rather than an image anyway,
385        // so leave each as its own canonical self.
386        if bytes.is_empty() {
387            canon_of.insert(idx, idx);
388            continue;
389        }
390        let canon = *first_by_content
391            .entry((bytes, res.px_w, res.px_h))
392            .or_insert(idx);
393        canon_of.insert(idx, canon);
394    }
395    let mut total: HashMap<usize, usize> = HashMap::new();
396    for (id, n) in uses {
397        let canon = canon_of.get(id).copied().unwrap_or(*id);
398        *total.entry(canon).or_default() += n;
399    }
400    canon_of
401        .into_iter()
402        .map(|(id, canon)| (id, (canon, total.get(&canon).copied().unwrap_or(0))))
403        .collect()
404}
405
406/// Serialize the pre-page-break `Vec<VertBox>` (`source` —
407/// `DocumentValue::reflow_source`, `None` when unavailable, e.g. a
408/// hand-built `DocumentValue` in a test) to a single, self-contained,
409/// REFLOWABLE HTML document, using generic system-font fallback (no
410/// `@font-face` block) — the base-14 twin of [`render_html_reflow_ttf_with`],
411/// exactly mirroring [`crate::render_html_fixed`]'s relationship to
412/// [`crate::render_html_fixed_ttf_with`].
413///
414/// `images`/`extras` are accepted for argument-for-argument symmetry with
415/// the faithful backend; Slice 1 did not read them, Slice 2 reads `images`
416/// for `Image` `<img>` data-URIs (TODO: still deferred, see `inline.rs`) and
417/// `extras` is superseded here by the more precise `links`/`dests` slices
418/// (`DocumentValue::reflow_links`/`reflow_dests` — `DecoId`-keyed, not
419/// `extras.annotations`/`destinations`'s page-absolute rects, see
420/// `Ctx::links`'s doc comment on why).
421#[allow(clippy::too_many_arguments)]
422pub fn render_html_reflow(
423    source: Option<&[VertBox]>,
424    geometry: &PageGeometry,
425    images: &[ImageResource],
426    extras: &DocExtras,
427    links: &[(DecoId, AnnotAction)],
428    dests: &[(DecoId, String)],
429) -> Result<String, HtmlError> {
430    render_html_reflow_impl(source, geometry, images, extras, links, dests, &[], None)
431}
432
433/// [`render_html_reflow`] plus the frame decorations
434/// (`DocumentValue::reflow_frame_decos`), so framed blocks draw their own
435/// decoration instead of nothing.
436pub fn render_html_reflow_with_decos(
437    source: Option<&[VertBox]>,
438    geometry: &PageGeometry,
439    images: &[ImageResource],
440    extras: &DocExtras,
441    links: &[(DecoId, AnnotAction)],
442    dests: &[(DecoId, String)],
443    frame_decos: &[(DecoId, FrameDecoration)],
444) -> Result<String, HtmlError> {
445    render_html_reflow_impl(source, geometry, images, extras, links, dests, frame_decos, None)
446}
447
448/// Same as [`render_html_reflow`], but rendering under a real
449/// [`TtfFontStore`] — every inline run's `<span>` gets an explicit
450/// `font-family` naming the `@font-face` this function's `<style>` block
451/// embeds for every physical font file actually referenced, exactly
452/// [`crate::render_html_fixed_ttf_with`]'s Slice-3 fidelity mitigation.
453#[allow(clippy::too_many_arguments)]
454pub fn render_html_reflow_ttf_with(
455    source: Option<&[VertBox]>,
456    geometry: &PageGeometry,
457    store: &TtfFontStore,
458    images: &[ImageResource],
459    extras: &DocExtras,
460    links: &[(DecoId, AnnotAction)],
461    dests: &[(DecoId, String)],
462) -> Result<String, HtmlError> {
463    render_html_reflow_impl(source, geometry, images, extras, links, dests, &[], Some(store))
464}
465
466/// [`render_html_reflow_ttf_with`] plus the frame decorations — the
467/// full-fidelity entry point the CLI uses.
468#[allow(clippy::too_many_arguments)]
469pub fn render_html_reflow_ttf_with_decos(
470    source: Option<&[VertBox]>,
471    geometry: &PageGeometry,
472    store: &TtfFontStore,
473    images: &[ImageResource],
474    extras: &DocExtras,
475    links: &[(DecoId, AnnotAction)],
476    dests: &[(DecoId, String)],
477    frame_decos: &[(DecoId, FrameDecoration)],
478) -> Result<String, HtmlError> {
479    render_html_reflow_impl(
480        source,
481        geometry,
482        images,
483        extras,
484        links,
485        dests,
486        frame_decos,
487        Some(store),
488    )
489}
490
491#[allow(clippy::too_many_arguments)]
492fn render_html_reflow_impl(
493    source: Option<&[VertBox]>,
494    geometry: &PageGeometry,
495    images: &[ImageResource],
496    extras: &DocExtras,
497    links: &[(DecoId, AnnotAction)],
498    dests: &[(DecoId, String)],
499    frame_decos: &[(DecoId, FrameDecoration)],
500    font_store: Option<&TtfFontStore>,
501) -> Result<String, HtmlError> {
502    // One read-only pass over the flow before anything is written: which
503    // `(font, size)` most of the text is in, and how much of it is CJK. Both
504    // are document-wide facts the per-run emitter needs BEFORE it emits its
505    // first run, so they cannot be accumulated as it goes.
506    let body_style = BodyStyle::dominant(source);
507    let image_canon = canonical_images(images, &body_style.image_uses);
508    let ctx = Ctx {
509        fonts: font_store,
510        used_fonts: RefCell::new(BTreeSet::new()),
511        links: links.iter().map(|(id, action)| (*id, action)).collect(),
512        dests: dests
513            .iter()
514            .map(|(id, name)| (*id, name.as_str()))
515            .collect(),
516        outline_by_dest: structure::outline_levels(&extras.outline),
517        emph_stack: RefCell::new(Vec::new()),
518        bullet_suppress: RefCell::new(0),
519        iframe_stack: RefCell::new(Vec::new()),
520        images,
521        body: body_style,
522        pending_glue: Cell::new(None),
523        last_char: Cell::new(None),
524        mono_run: Cell::new(false),
525        tabular_rules: RefCell::new(Vec::new()),
526        frame_decos: frame_decos.iter().map(|(id, d)| (*id, d)).collect(),
527        footnotes: RefCell::new(Vec::new()),
528        footnote_seq: Cell::new(0),
529        shared_images: RefCell::new(Vec::new()),
530        image_canon,
531        open_run: RefCell::new(None),
532    };
533
534    let mut body = String::new();
535    // No generated table of contents. `extras.outline` still drives heading
536    // promotion and the `id=` anchors that in-document links land on, but a
537    // document that wants a contents page TYPESETS one (`stdjabook`'s
538    // `\table-of-contents`), and emitting a second, differently-styled copy
539    // above the title duplicated it in every real manual.
540    body.push_str("<div class=\"doc\">\n");
541    if let Some(vboxes) = source {
542        block::walk_vboxes(&mut body, vboxes, &ctx);
543    } else {
544        // No captured pre-page-break flow (e.g. a hand-built `DocumentValue`
545        // in a unit test that never populated `reflow_source`) — an empty
546        // document body rather than a panic; still valid, well-formed HTML.
547        body.push_str("<p class=\"para reflow-empty\">(no reflow source captured)</p>\n");
548    }
549    body.push_str("</div>\n");
550
551    let mut out = String::new();
552    // `hyphens: auto` is inert without a language — a browser will not guess
553    // one — so the root carries the language the text actually is. The
554    // threshold is deliberately low: a Japanese document interleaves enough
555    // Latin (code, package names, math) that "mostly Japanese" is well under
556    // half, while an English document with a few kana in an example is well
557    // under a tenth.
558    let lang = if ctx.body.cjk_ratio > 0.1 { "ja" } else { "en" };
559    let _ = write!(
560        out,
561        "<!doctype html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n\
562         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
563    );
564    out.push_str("<style>\n");
565    out.push_str(&css::stylesheet(geometry, &ctx));
566    // Reads state the body walk filled in, so it must come after it: which
567    // images were placed often enough to be worth sharing. (No
568    // `@font-face` counterpart — this backend names fonts rather than
569    // embedding them; see `fonts::reflow_font_stack`.)
570    out.push_str(&css::shared_image_rules(&ctx));
571    out.push_str("</style>\n</head>\n<body>\n");
572    out.push_str(&body);
573    out.push_str("</body>\n</html>\n");
574    Ok(out)
575}