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}