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}