Skip to main content

rustyfi_html/reflow/
mod.rs

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