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    /// The line currently being built ends with a hyphen the LINE BREAKER
235    /// inserted (`InlineMarkKind::BreakHyphen`), so rejoining it to the next
236    /// line must drop that hyphen.
237    ///
238    /// Set per line and cleared by `block.rs` at each line boundary. Before
239    /// this existed the rejoin guessed from the text's shape — "ends with
240    /// letter+hyphen, next line starts lowercase" — and the guess deleted
241    /// authored hyphens: a paragraph wrapping at `code-printer` rendered as
242    /// `codeprinter`.
243    pub(crate) break_hyphen: Cell<bool>,
244    /// Rules belonging to a table whose own `TabularBox` does not carry them,
245    /// as `(width, height, rules)`.
246    ///
247    /// `easytable` draws a table as TWO overlaid `tabular`s at one anchor:
248    /// one holds the rules over PHANTOM cells, the other the real content and
249    /// no rules at all (its own source shows the shape plainly — `ib-rule`
250    /// and `ib-table`, both `draw-text` into one `inline-graphics`). Rendered
251    /// independently, the rules land on a table with nothing in it — dropped
252    /// as empty — and the visible table comes out with no rules. Pushed by
253    /// `inline.rs`'s text-only graphics path, which is the only place the two
254    /// halves are visible together, and matched back by geometry.
255    pub(crate) tabular_rules: RefCell<Vec<(f64, f64, Vec<GraphicsElem>)>>,
256    /// `DecoId -> the frame's own decoration`, from
257    /// `DocumentValue::reflow_frame_decos`.
258    ///
259    /// A block frame's decoration is a lang-side callback, and this backend
260    /// has no page grid to run it on — which is why `.frame` drew nothing at
261    /// all, and every `stdjabook` title block, `+code` panel and framed
262    /// figure arrived as bare text. `fire_hooks` already runs the callback
263    /// for the PDF path; this is the same graphics, recorded box-local at the
264    /// frame's natural size so it can be SCALED to whatever width the reader
265    /// gives it rather than replayed at a fixed one.
266    pub(crate) frame_decos: HashMap<DecoId, &'a FrameDecoration>,
267    /// Footnote bodies whose reference marker has been emitted but whose
268    /// text has not yet been placed. `block.rs`'s `flush_para` drains this
269    /// immediately after closing the referencing paragraph — see this
270    /// crate's `reflow` module doc comment on why "just after the
271    /// paragraph" is where a footnote belongs once there is no page foot to
272    /// put it at.
273    pub(crate) footnotes: RefCell<Vec<(usize, String)>>,
274    /// Monotonic footnote number, shared by the `<sup>` reference and the
275    /// `<aside>` body so the two can link to each other.
276    pub(crate) footnote_seq: Cell<usize>,
277    /// Canonical `ImageId`s of images placed more than once, in first-use
278    /// order. Their bytes go into the stylesheet ONCE, as a
279    /// `background-image` rule (`css.rs`'s `shared_image_rules`), instead of
280    /// once per placement. See [`Ctx::image_sharing`].
281    pub(crate) shared_images: RefCell<Vec<usize>>,
282    /// Every `ImageId` mapped to the LOWEST `ImageId` holding identical
283    /// pixels, and how many placements that canonical image has in total.
284    ///
285    /// Content, not identity, is what has to be deduplicated: each
286    /// `include-image` call mints a fresh `ImageResource` even for a file
287    /// already loaded, so `figbox`'s manual holds seventeen distinct
288    /// `ImageId`s covering two actual pictures. Keying on the id alone found
289    /// nothing to share.
290    image_canon: HashMap<usize, (usize, usize)>,
291    /// The `style` of the `<span class="run">` currently left OPEN, if any.
292    /// A run whose style matches simply appends its text to it, so a word
293    /// the box stream split into chunks — and a Japanese phrase it split
294    /// into individual characters, which is every CJK run at any size other
295    /// than the body's — comes out as ONE span of ordinary text rather than
296    /// one span per chunk. Every emitter that writes something which is not
297    /// part of the run (a tag, a strut, an `<svg>`) closes it first via
298    /// `inline::close_run`; a space and a soft hyphen deliberately do not,
299    /// since neither carries style and both belong inside the word.
300    pub(crate) open_run: RefCell<Option<String>>,
301}
302
303impl Ctx<'_> {
304    /// Resolve `font` to a CSS `font-family` VALUE — the real family name
305    /// the font file declares, followed by generic fallbacks
306    /// (`fonts::reflow_font_stack`). `None` in base-14 mode, and for a file
307    /// whose `name` table declares no usable family, in which case the
308    /// stylesheet's own stack applies.
309    ///
310    /// Unlike the faithful backend's namesake this NAMES rather than
311    /// embeds — see `fonts::reflow_font_stack` for the argument. The
312    /// `used_fonts` bookkeeping is kept anyway: it costs nothing and keeps
313    /// the two backends' shape aligned should a subsetting embedder ever
314    /// make embedding affordable here.
315    pub(crate) fn font_family_for(&self, font: FontKey) -> Option<String> {
316        let store = self.fonts?;
317        let file_idx = store.file_index(font);
318        self.used_fonts.borrow_mut().insert(file_idx);
319        let family = store.file_family_name(file_idx)?;
320        Some(crate::fonts::reflow_font_stack(&family))
321    }
322
323    /// Whether `font` is a fixed-pitch face. Unlike [`Ctx::font_family_for`]
324    /// this does NOT mark the file used: asking what a face IS must not pull
325    /// it into the document's font set.
326    pub(crate) fn is_monospace(&self, font: Option<FontKey>) -> bool {
327        let (Some(store), Some(font)) = (self.fonts, font) else {
328            return false;
329        };
330        store
331            .file_family_name(store.file_index(font))
332            .is_some_and(|f| crate::fonts::is_monospace_family(&f))
333    }
334
335    /// Record that a glue box of `natural_pt` natural width stands here.
336    /// Nothing is written yet: whether it becomes a space depends on the
337    /// character that follows (`text::wants_space`), which is not known
338    /// until the next run arrives.
339    pub(crate) fn note_glue(&self, natural_pt: f64) {
340        let merged = match self.pending_glue.get() {
341            Some(prev) if prev >= natural_pt => prev,
342            _ => natural_pt,
343        };
344        self.pending_glue.set(Some(merged));
345    }
346
347    /// Resolve the pending glue against the character about to be written
348    /// (`next`, `None` before an opaque box or at a paragraph edge),
349    /// appending a space to `out` if one is warranted.
350    pub(crate) fn resolve_glue(&self, out: &mut String, next: Option<char>) {
351        if let Some(width) = self.pending_glue.take() {
352            if text::wants_space(self.last_char.get(), next, width) {
353                out.push(' ');
354            }
355        }
356    }
357
358    /// Drop any pending glue and forget the last character — used at a hard
359    /// boundary (a new paragraph, a table cell, a footnote body) where a
360    /// space carried over from the previous context would be wrong.
361    pub(crate) fn reset_flow(&self) {
362        self.pending_glue.set(None);
363        self.last_char.set(None);
364    }
365
366    /// For an `ImageId`: the canonical id of the image it holds, and whether
367    /// that image is placed more than once (and so should be shared through
368    /// the stylesheet rather than repeated inline). See `image_canon`.
369    pub(crate) fn image_sharing(&self, id: usize) -> (usize, bool) {
370        match self.image_canon.get(&id) {
371            Some(&(canon, uses)) => (canon, uses > 1),
372            None => (id, false),
373        }
374    }
375}
376
377/// Group `images` by CONTENT and fold in each group's total placement count
378/// from the pre-pass, producing `Ctx::image_canon`. Two resources are the
379/// same picture when their pixel dimensions and their bytes agree — the
380/// original JPEG stream when there is one (which is also what
381/// `image::data_uri` will emit), the decoded samples otherwise.
382fn canonical_images(
383    images: &[ImageResource],
384    uses: &HashMap<usize, usize>,
385) -> HashMap<usize, (usize, usize)> {
386    let mut first_by_content: HashMap<(&[u8], u32, u32), usize> = HashMap::new();
387    let mut canon_of: HashMap<usize, usize> = HashMap::new();
388    for (idx, res) in images.iter().enumerate() {
389        let bytes: &[u8] = match &res.jpeg_dct {
390            Some(j) => &j.bytes,
391            None => &res.samples,
392        };
393        // An imported PDF page has neither, so every one of them would hash
394        // alike; they render as a labelled box rather than an image anyway,
395        // so leave each as its own canonical self.
396        if bytes.is_empty() {
397            canon_of.insert(idx, idx);
398            continue;
399        }
400        let canon = *first_by_content
401            .entry((bytes, res.px_w, res.px_h))
402            .or_insert(idx);
403        canon_of.insert(idx, canon);
404    }
405    let mut total: HashMap<usize, usize> = HashMap::new();
406    for (id, n) in uses {
407        let canon = canon_of.get(id).copied().unwrap_or(*id);
408        *total.entry(canon).or_default() += n;
409    }
410    canon_of
411        .into_iter()
412        .map(|(id, canon)| (id, (canon, total.get(&canon).copied().unwrap_or(0))))
413        .collect()
414}
415
416/// Serialize the pre-page-break `Vec<VertBox>` (`source` —
417/// `DocumentValue::reflow_source`, `None` when unavailable, e.g. a
418/// hand-built `DocumentValue` in a test) to a single, self-contained,
419/// REFLOWABLE HTML document, using generic system-font fallback (no
420/// `@font-face` block) — the base-14 twin of [`render_html_reflow_ttf_with`],
421/// exactly mirroring [`crate::render_html_fixed`]'s relationship to
422/// [`crate::render_html_fixed_ttf_with`].
423///
424/// `images`/`extras` are accepted for argument-for-argument symmetry with
425/// the faithful backend; Slice 1 did not read them, Slice 2 reads `images`
426/// for `Image` `<img>` data-URIs (TODO: still deferred, see `inline.rs`) and
427/// `extras` is superseded here by the more precise `links`/`dests` slices
428/// (`DocumentValue::reflow_links`/`reflow_dests` — `DecoId`-keyed, not
429/// `extras.annotations`/`destinations`'s page-absolute rects, see
430/// `Ctx::links`'s doc comment on why).
431#[allow(clippy::too_many_arguments)]
432pub fn render_html_reflow(
433    source: Option<&[VertBox]>,
434    geometry: &PageGeometry,
435    images: &[ImageResource],
436    extras: &DocExtras,
437    links: &[(DecoId, AnnotAction)],
438    dests: &[(DecoId, String)],
439) -> Result<String, HtmlError> {
440    render_html_reflow_impl(source, geometry, images, extras, links, dests, &[], None)
441}
442
443/// [`render_html_reflow`] plus the frame decorations
444/// (`DocumentValue::reflow_frame_decos`), so framed blocks draw their own
445/// decoration instead of nothing.
446pub fn render_html_reflow_with_decos(
447    source: Option<&[VertBox]>,
448    geometry: &PageGeometry,
449    images: &[ImageResource],
450    extras: &DocExtras,
451    links: &[(DecoId, AnnotAction)],
452    dests: &[(DecoId, String)],
453    frame_decos: &[(DecoId, FrameDecoration)],
454) -> Result<String, HtmlError> {
455    render_html_reflow_impl(source, geometry, images, extras, links, dests, frame_decos, None)
456}
457
458/// Same as [`render_html_reflow`], but rendering under a real
459/// [`TtfFontStore`] — every inline run's `<span>` gets an explicit
460/// `font-family` naming the `@font-face` this function's `<style>` block
461/// embeds for every physical font file actually referenced, exactly
462/// [`crate::render_html_fixed_ttf_with`]'s Slice-3 fidelity mitigation.
463#[allow(clippy::too_many_arguments)]
464pub fn render_html_reflow_ttf_with(
465    source: Option<&[VertBox]>,
466    geometry: &PageGeometry,
467    store: &TtfFontStore,
468    images: &[ImageResource],
469    extras: &DocExtras,
470    links: &[(DecoId, AnnotAction)],
471    dests: &[(DecoId, String)],
472) -> Result<String, HtmlError> {
473    render_html_reflow_impl(source, geometry, images, extras, links, dests, &[], Some(store))
474}
475
476/// [`render_html_reflow_ttf_with`] plus the frame decorations — the
477/// full-fidelity entry point the CLI uses.
478#[allow(clippy::too_many_arguments)]
479pub fn render_html_reflow_ttf_with_decos(
480    source: Option<&[VertBox]>,
481    geometry: &PageGeometry,
482    store: &TtfFontStore,
483    images: &[ImageResource],
484    extras: &DocExtras,
485    links: &[(DecoId, AnnotAction)],
486    dests: &[(DecoId, String)],
487    frame_decos: &[(DecoId, FrameDecoration)],
488) -> Result<String, HtmlError> {
489    render_html_reflow_impl(
490        source,
491        geometry,
492        images,
493        extras,
494        links,
495        dests,
496        frame_decos,
497        Some(store),
498    )
499}
500
501#[allow(clippy::too_many_arguments)]
502fn render_html_reflow_impl(
503    source: Option<&[VertBox]>,
504    geometry: &PageGeometry,
505    images: &[ImageResource],
506    extras: &DocExtras,
507    links: &[(DecoId, AnnotAction)],
508    dests: &[(DecoId, String)],
509    frame_decos: &[(DecoId, FrameDecoration)],
510    font_store: Option<&TtfFontStore>,
511) -> Result<String, HtmlError> {
512    // One read-only pass over the flow before anything is written: which
513    // `(font, size)` most of the text is in, and how much of it is CJK. Both
514    // are document-wide facts the per-run emitter needs BEFORE it emits its
515    // first run, so they cannot be accumulated as it goes.
516    let body_style = BodyStyle::dominant(source);
517    let image_canon = canonical_images(images, &body_style.image_uses);
518    let ctx = Ctx {
519        fonts: font_store,
520        used_fonts: RefCell::new(BTreeSet::new()),
521        links: links.iter().map(|(id, action)| (*id, action)).collect(),
522        dests: dests
523            .iter()
524            .map(|(id, name)| (*id, name.as_str()))
525            .collect(),
526        outline_by_dest: structure::outline_levels(&extras.outline),
527        emph_stack: RefCell::new(Vec::new()),
528        bullet_suppress: RefCell::new(0),
529        iframe_stack: RefCell::new(Vec::new()),
530        images,
531        body: body_style,
532        pending_glue: Cell::new(None),
533        last_char: Cell::new(None),
534        mono_run: Cell::new(false),
535        break_hyphen: Cell::new(false),
536        tabular_rules: RefCell::new(Vec::new()),
537        frame_decos: frame_decos.iter().map(|(id, d)| (*id, d)).collect(),
538        footnotes: RefCell::new(Vec::new()),
539        footnote_seq: Cell::new(0),
540        shared_images: RefCell::new(Vec::new()),
541        image_canon,
542        open_run: RefCell::new(None),
543    };
544
545    let mut body = String::new();
546    // No generated table of contents. `extras.outline` still drives heading
547    // promotion and the `id=` anchors that in-document links land on, but a
548    // document that wants a contents page TYPESETS one (`stdjabook`'s
549    // `\table-of-contents`), and emitting a second, differently-styled copy
550    // above the title duplicated it in every real manual.
551    body.push_str("<div class=\"doc\">\n");
552    if let Some(vboxes) = source {
553        block::walk_vboxes(&mut body, vboxes, &ctx);
554    } else {
555        // No captured pre-page-break flow (e.g. a hand-built `DocumentValue`
556        // in a unit test that never populated `reflow_source`) — an empty
557        // document body rather than a panic; still valid, well-formed HTML.
558        body.push_str("<p class=\"para reflow-empty\">(no reflow source captured)</p>\n");
559    }
560    body.push_str("</div>\n");
561
562    let mut out = String::new();
563    // `hyphens: auto` is inert without a language — a browser will not guess
564    // one — so the root carries the language the text actually is. The
565    // threshold is deliberately low: a Japanese document interleaves enough
566    // Latin (code, package names, math) that "mostly Japanese" is well under
567    // half, while an English document with a few kana in an example is well
568    // under a tenth.
569    let lang = if ctx.body.cjk_ratio > 0.1 { "ja" } else { "en" };
570    let _ = write!(
571        out,
572        "<!doctype html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n\
573         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
574    );
575    out.push_str("<style>\n");
576    out.push_str(&css::stylesheet(geometry, &ctx));
577    // Reads state the body walk filled in, so it must come after it: which
578    // images were placed often enough to be worth sharing. (No
579    // `@font-face` counterpart — this backend names fonts rather than
580    // embedding them; see `fonts::reflow_font_stack`.)
581    out.push_str(&css::shared_image_rules(&ctx));
582    out.push_str("</style>\n</head>\n<body>\n");
583    out.push_str(&body);
584    out.push_str("</body>\n</html>\n");
585    Ok(out)
586}