Skip to main content

rustyfi_backend/
hbox.rs

1use crate::font::FontKey;
2use crate::graphics::{Color, GraphicsElem};
3use crate::length::Length;
4use crate::math::MathGlyph;
5use crate::tabular::TabularBox;
6use crate::vbox::VertBox;
7
8/// Which font/size a string box is set in (`horz_string_info`).
9#[derive(Clone, Debug, PartialEq)]
10pub struct HorzStringInfo {
11    pub font: FontKey,
12    pub size: Length,
13    /// A manual baseline raise (`ScriptFont::rising` scaled by the
14    /// run's font size — `fontInfo.ml`'s `get_font_with_ratio`). Both PDF
15    /// writers add it to the placed `ty` before `Tj`.
16    pub rising: Length,
17    /// `set-text-color`'s value at the time this run/glyph was built
18    /// (`Context::text_color`). `Color::Gray(0.0)` (black) is the default and
19    /// both writers emit NO color op for a black run.
20    pub color: Color,
21}
22
23/// An index into `DocumentValue::images` (rustyfi-lang). Boxes carry this
24/// rather than the decoded bytes, so cloning a box (routine during line
25/// breaking) never copies image data.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub struct ImageId(pub usize);
28
29/// An opaque index into a lang-side table of deferred page-break-hook
30/// closures (`Interp::hooks`). `break_pages` places the box this token lives
31/// in like any other content and never learns what the hook computes; a
32/// lang-side post-pass (`fire_hooks`) reads the token back once geometry is
33/// final. `DecoId`/`GraphicsFnId` below follow the same pattern.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35pub struct HookId(pub usize);
36
37/// Deferred *decoration* closures (`Interp::decos`) for frames.
38/// `fire_hooks` fires one with the frame's placed `(x, y, w, h, d)` and
39/// accumulates the returned graphics onto the page.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub struct DecoId(pub usize);
42
43/// Deferred `inline-graphics-outer` callbacks (`Interp::outer_graphics`),
44/// read back by `resolve_outer_graphics_*` once line layout has resolved the
45/// box's width.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47pub struct GraphicsFnId(pub usize);
48
49/// A decoded raster image, referenced via its `ImageId`. Mirrors v0.0.6's
50/// `ImageInfo` (`imageInfo.ml`).
51///
52/// Every source format is flattened to 8-bit `DeviceRGB` and any alpha
53/// channel is dropped; transparency (`/SMask`) is unimplemented. `jpeg_dct`
54/// additionally carries the source's original, still-DCT-encoded bytes for a
55/// baseline JPEG, so `write_image_xobjects` can embed those directly instead
56/// of re-encoding the flattened RGB8 samples.
57#[derive(Clone, Debug, PartialEq)]
58pub struct ImageResource {
59    /// Row-major, top-to-bottom, 3-bytes-per-pixel RGB8 samples with no
60    /// padding — exactly what `pdf_writer::Chunk::image_xobject` wants for a
61    /// `DeviceRGB` image at `bits_per_component(8)`.
62    pub samples: Vec<u8>,
63    pub px_w: u32,
64    pub px_h: u32,
65    /// `Some` for a baseline (or extended-sequential) 8-bit JPEG with 1 or 3
66    /// color components — see `sniff_baseline_jpeg_dct` for exactly which
67    /// qualify. `None` for every non-JPEG source, and for a JPEG this port
68    /// cannot map to a PDF colorspace without guessing (progressive, 12-bit,
69    /// or 4-component CMYK/YCCK).
70    pub jpeg_dct: Option<JpegDct>,
71    /// `Some` when this resource is an imported page of an external PDF
72    /// (`load-pdf-image`) rather than a decoded raster image;
73    /// `samples`/`px_w`/`px_h` are then left empty, and PDF-page consumers
74    /// branch on this field instead.
75    pub pdf: Option<PdfPageResource>,
76}
77
78/// An embedded page of an external PDF (`load-pdf-image`), carrying just
79/// enough of the source page's object graph to re-emit it as a PDF **Form
80/// XObject**. Deliberately `lopdf`-free plain data so `rustyfi-backend` need
81/// not depend on `lopdf`.
82#[derive(Clone, Debug, PartialEq)]
83pub struct PdfPageResource {
84    /// The source page's `/MediaBox`, `(x0, y0, x1, y1)` in raw PDF points
85    /// (upstream `loadPdf.ml`: MediaBox only, no `/CropBox` fallback).
86    pub media_box: (f64, f64, f64, f64),
87    /// The page's content stream(s), already inflated (`/FlateDecode`
88    /// resolved) and concatenated (with a separating space per PDF rules
89    /// when a page has more than one content stream) — ready to wrap
90    /// verbatim in a Form XObject's stream body.
91    pub content: Vec<u8>,
92    /// The imported object subtree reachable from the page's `/Resources`,
93    /// self-contained and keyed by source object number so the writer can
94    /// remap references to freshly allocated output `Ref`s. Local id `0` is
95    /// reserved (PDF object number 0 is always free/unused in a well-formed
96    /// file) and holds the page's own `/Resources` dictionary itself
97    /// (whether it was a direct or an indirect object in the source); every
98    /// other entry is a real source object number.
99    pub resources: ImportedObjects,
100}
101
102/// A serialized subtree of a *foreign* PDF's object graph. See
103/// `PdfPageResource::resources` for the local-id convention.
104#[derive(Clone, Debug, Default, PartialEq)]
105pub struct ImportedObjects(pub Vec<(u32, ObjRepr)>);
106
107/// A minimal sum type mirroring the PDF object grammar, just enough to
108/// re-emit an imported object verbatim. `Ref(u32)` refers to another entry's
109/// local id in the same `ImportedObjects` table; the writer treats an
110/// unresolved `Ref` as an importer bug rather than fetching it.
111#[derive(Clone, Debug, PartialEq)]
112pub enum ObjRepr {
113    Null,
114    Bool(bool),
115    Int(i64),
116    Real(f64),
117    /// A PDF name's raw bytes, without the leading `/` and with `#xx`
118    /// escapes already decoded (mirrors `lopdf::Object::Name`).
119    Name(Vec<u8>),
120    /// A PDF string's raw bytes (mirrors `lopdf::Object::String`, literal or
121    /// hex — both collapse to bytes here since we only ever re-emit them).
122    String(Vec<u8>),
123    Ref(u32),
124    Array(Vec<ObjRepr>),
125    Dict(Vec<(Vec<u8>, ObjRepr)>),
126    /// A stream object: its dictionary entries (excluding `/Length`, which
127    /// the writer derives) plus already-decompressed content bytes. The
128    /// writer decides filtering/compression at write time; the imported
129    /// content is kept in cleartext form here so no `lopdf`-specific codec
130    /// state needs to cross the boundary.
131    Stream(Vec<(Vec<u8>, ObjRepr)>, Vec<u8>),
132}
133
134/// The original, still-DCT-encoded bytes of a source JPEG, plus enough
135/// metadata (`components`) for the writer to pick the matching
136/// `/ColorSpace`. Never re-derived from the flattened `samples`, which have
137/// already lost the JPEG's own subsampling/quantization.
138#[derive(Clone, Debug, PartialEq)]
139pub struct JpegDct {
140    /// The complete original file contents, `FFD8` (SOI) to `FFD9` (EOI),
141    /// byte-for-byte — exactly the stream a `/Filter /DCTDecode` XObject wants.
142    pub bytes: Vec<u8>,
143    /// Color components from the JPEG's SOF marker: `1` (grayscale ->
144    /// `/DeviceGray`) or `3` (YCbCr/RGB -> `/DeviceRGB`).
145    /// `sniff_baseline_jpeg_dct` never returns any other value.
146    pub components: u8,
147}
148
149impl ImageResource {
150    /// Scan raw file bytes for a JPEG **SOF0** (baseline DCT) or **SOF1**
151    /// (extended sequential DCT) marker — the two variants a `/DCTDecode`
152    /// XObject can safely wrap verbatim, matching upstream's own JPEG
153    /// special-case (`imageInfo.ml`'s bypass of decode/re-encode for `Jpeg`).
154    /// Returns `None`, meaning "fall back to the flattened RGB8 embedding",
155    /// for:
156    ///
157    /// - anything that isn't a JPEG (no `FFD8` SOI marker);
158    /// - a malformed/truncated JPEG (a segment length running past the end of
159    ///   the buffer, or scan data reached before any SOF marker);
160    /// - progressive, lossless, arithmetic-coded, or hierarchical JPEGs (any
161    ///   SOF other than `0xC0`/`0xC1`) — viewer `DCTDecode` support for these
162    ///   is inconsistent, so only the two universally-supported variants are
163    ///   trusted;
164    /// - non-8-bit sample precision;
165    /// - anything other than 1 or 3 color components — in particular
166    ///   4-component CMYK/YCCK, whose correct embedding depends on an Adobe
167    ///   APP14 transform flag (some store inverted samples) this port does not
168    ///   interpret.
169    ///
170    /// `bytes` is consumed so the `Some` case hands the file contents into
171    /// `JpegDct` with no copy.
172    pub fn sniff_baseline_jpeg_dct(bytes: Vec<u8>) -> Option<JpegDct> {
173        if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
174            return None; // no SOI marker: not a JPEG.
175        }
176        let mut i = 2usize;
177        while i < bytes.len() {
178            if bytes[i] != 0xFF {
179                return None; // not aligned on a marker; bail rather than guess.
180            }
181            // Marker codes may be preceded by any number of 0xFF fill bytes.
182            let mut j = i + 1;
183            while j < bytes.len() && bytes[j] == 0xFF {
184                j += 1;
185            }
186            if j >= bytes.len() {
187                return None;
188            }
189            let marker = bytes[j];
190            i = j + 1;
191            // Standalone markers carry no length-prefixed segment: SOI
192            // (stray, shouldn't recur but is harmless), EOI, TEM, RSTn.
193            if marker == 0xD8 || marker == 0xD9 || marker == 0x01 || (0xD0..=0xD7).contains(&marker)
194            {
195                continue;
196            }
197            if i + 2 > bytes.len() {
198                return None;
199            }
200            let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize;
201            if seg_len < 2 || i + seg_len > bytes.len() {
202                return None;
203            }
204            if marker == 0xDA {
205                return None; // start-of-scan reached before any SOF: bail.
206            }
207            // SOF0..SOF15 (0xC0..0xCF) except 0xC4 (DHT), 0xC8 (JPG
208            // extension, reserved), 0xCC (DAC) — those codes overlap the
209            // SOF range but aren't frame headers.
210            let is_sof = (0xC0..=0xCF).contains(&marker) && ![0xC4, 0xC8, 0xCC].contains(&marker);
211            if is_sof {
212                if marker != 0xC0 && marker != 0xC1 {
213                    return None; // progressive/lossless/hierarchical: bail.
214                }
215                // Segment payload after the 2-byte length: precision(1),
216                // height(2 BE), width(2 BE), num_components(1).
217                let payload = &bytes[i + 2..i + seg_len];
218                if payload.len() < 6 {
219                    return None;
220                }
221                let precision = payload[0];
222                let components = payload[5];
223                return if precision == 8 && (components == 1 || components == 3) {
224                    Some(JpegDct { bytes, components })
225                } else {
226                    None
227                };
228            }
229            i += seg_len;
230        }
231        None
232    }
233
234    /// Intrinsic dimensions for aspect-ratio math (`use-image-by-width`):
235    /// pixel extents for a raster resource, MediaBox point extents for an
236    /// imported PDF page. Both ratios are dimensionless (px/px or pt/pt), so
237    /// callers apply the same `height = width * ih/iw` regardless of kind —
238    /// only the placement CTM needs to know which units these are.
239    pub fn intrinsic_dims_pt(&self) -> (f64, f64) {
240        if let Some(pdf) = &self.pdf {
241            let (x0, y0, x1, y1) = pdf.media_box;
242            (x1 - x0, y1 - y0)
243        } else {
244            (self.px_w as f64, self.px_h as f64)
245        }
246    }
247}
248
249/// A milestone-1 subset of `pure_horz_box` from horzBox.ml, keeping its
250/// vocabulary so the full port extends rather than replaces it.
251///
252/// The `#[subast]` list names every *other* box type reachable from a field
253/// of this enum; self-recursion (`Discretionary`, `Frame`) is implicit. It is
254/// what [`crate::visit`]'s generated traversal descends through, and it is
255/// **unchecked on stable Rust** — see that module's "The one trap" note and
256/// the `tests/visit_reachability.rs` test standing in for the missing check.
257#[derive(Clone, Debug, PartialEq, syan::visit::Ast)]
258#[subast(
259    crate::hbox::PureHorzBox,
260    crate::vbox::VertBox,
261    crate::graphics::GraphicsElem,
262    crate::tabular::TabularBox
263)]
264pub enum PureHorzBox {
265    /// Fixed text with pre-measured dimensions.
266    InnerString {
267        info: HorzStringInfo,
268        text: String,
269        width: Length,
270        height: Length,
271        depth: Length,
272    },
273    /// Interword glue.
274    OuterEmpty {
275        natural: Length,
276        shrinkable: Length,
277        stretchable: Length,
278    },
279    /// Infinitely stretchable glue (`inline-fil`).
280    OuterFil,
281    /// A fixed-width empty box with no stretch/shrink (`inline-skip`;
282    /// v0.0.6: `PHSFixedEmpty`). Unlike `OuterEmpty` this is never a legal
283    /// line-break point (see `is_glue`).
284    FixedEmpty { width: Length },
285    /// A raster image placed at a fixed on-page size (`use-image-by-width`).
286    /// `width`/`height` are already-computed on-page dimensions (v0.0.6
287    /// `ImageInfo.get_height_from_width`). Like `FixedEmpty`, never a legal
288    /// line-break point (`is_glue`).
289    Image {
290        width: Length,
291        height: Length,
292        image: ImageId,
293    },
294    /// A break point that may or may not be taken (v0.0.6's
295    /// `LBDiscretionary(penalty, id, pre, post_nobreak, post_break)`,
296    /// `ref:src/backend/lineBreakBox.ml:22-27`). If the paragraph breaker
297    /// chooses to break here, `pre_break` renders at the end of the closed
298    /// line and `post_break` at the start of the next; otherwise `no_break`
299    /// renders in its place. UAX#14 only needs zero-width inter-chunk
300    /// break points with all three slots empty. Unlike `OuterEmpty`/`OuterFil`
301    /// this is not "glue" (see `is_glue`) — it is scored separately via
302    /// `is_break_point`/`break_penalty`.
303    Discretionary {
304        penalty: i32,
305        pre_break: Vec<PureHorzBox>,
306        post_break: Vec<PureHorzBox>,
307        no_break: Vec<PureHorzBox>,
308    },
309    /// A box carrying resolved `graphics` elements (`inline-graphics`;
310    /// v0.0.6: `PHGFixedGraphics`), coordinates already relative to the
311    /// box's baseline-left origin. Carries a real depth (graphics can extend
312    /// below the baseline), so both `height` and `depth` feed line metrics.
313    /// Never a legal line-break point (see `is_glue`).
314    Graphics {
315        width: Length,
316        height: Length,
317        depth: Length,
318        elems: Vec<GraphicsElem>,
319        /// True when the callback ignored its placed-point argument, so its
320        /// `elems` are PAGE-ABSOLUTE (e.g. a slydifi frame background /
321        /// full-page decoration built with `fun _ -> …`). Such graphics must
322        /// be emitted with an IDENTITY `cm` — NOT translated by the box's
323        /// placed position — otherwise the whole decoration shifts off the
324        /// page (the box is often placed at a negative text-origin). For an
325        /// ordinary position-relative callback this is false and the writer's
326        /// per-box `cm` translate applies as usual.
327        origin_independent: bool,
328    },
329    /// `inline-graphics-outer` (v0.0.6 `PHGOuterFilGraphics`,
330    /// vminst.ml:1891): a graphics box whose WIDTH stretches like
331    /// `inline-fil` (upstream widinfo `{natural = 0; stretchable = Fils(1)}`,
332    /// lineBreak.ml:40-48). `width` starts at ZERO and is written by
333    /// `justify_line` with the box's per-fil slack share; the box is then
334    /// replaced by a resolved `Graphics` in a lang-side post-pass that fires
335    /// `fn_id`'s callback with that width. NOT glue (upstream's box is pure
336    /// content, never a break point), but counted as a fil by
337    /// `measure`/`justify_line`.
338    GraphicsOuter {
339        height: Length,
340        depth: Length,
341        width: Length,
342        fn_id: GraphicsFnId,
343    },
344    /// A laid-out inline math run (`${…}`): one box carrying its own
345    /// pre-shifted sub-glyphs, each with a vertical offset relative to this
346    /// box's baseline (`MathGlyph::dy`) — the line model has only a
347    /// horizontal `dx` per box and a single `baseline_y` per line, so a
348    /// superscript can't be a separate box. `width`/`height`/`depth` are the
349    /// run's outer metrics (computed by `read_math`), so the line breaker
350    /// never re-enters the math engine. Never a legal line-break point (see
351    /// `is_glue`) — a math run is laid out and flowed atomically.
352    ///
353    /// `rules`: filled paths the run needs alongside its glyphs — the
354    /// fraction bar and radical sign/overbar are `Fill`s, not glyphs, since
355    /// neither is drawable through a font's `Tj`. Box-local, y-**up**
356    /// coordinates relative to this box's own baseline-left origin, exactly
357    /// `PureHorzBox::Graphics::elems`' convention. `natural_width` is
358    /// unaffected — a bar/radical-sign always sits within `glyphs`'
359    /// already-measured span. Empty for the `read_math` path and for every
360    /// atom `layout_math_atom` doesn't specially handle.
361    Math {
362        width: Length,
363        height: Length,
364        depth: Length,
365        glyphs: Vec<MathGlyph>,
366        rules: Vec<GraphicsElem>,
367    },
368    /// A deferred page-break hook (`hook-page-break`; v0.0.6's
369    /// `PHGHookPageBreak`). Zero-width, renders nothing.
370    HookPageBreak { id: HookId },
371    /// A ruled grid box (`tabular`; v0.0.6's `PHGFixedTabular`), carrying
372    /// each cell's already-laid-out inline run (`tabular::TabularCellBox`)
373    /// plus the resolved rule graphics from the user's callback. The PDF
374    /// writers recurse into it in `emit_box`, which is also where its three
375    /// coordinate frames are reconciled.
376    Tabular(TabularBox),
377    /// An inline box carrying a whole block (`embed-block-top`; upstream's
378    /// `PHGEmbeddedVert`/`HorzEmbeddedVertBreakable`). `block` is already
379    /// broken into `VertBox` lines, which the writer stacks from the box's
380    /// placed origin. ATOMIC — it does not split across a page boundary.
381    EmbeddedBlock {
382        width: Length,
383        height: Length,
384        depth: Length,
385        block: Vec<VertBox>,
386        /// Which of the block's lines sits on the surrounding text baseline:
387        /// `false` = the FIRST line (`embed-block-top`, `adjust_to_first_line`),
388        /// `true` = the LAST line (`embed-block-bottom`, `adjust_to_last_line`).
389        /// Governs both this box's height/depth split and where the writers
390        /// anchor the block's inner lines (`place_embedded_block`).
391        anchor_last: bool,
392        /// Built by `embed-block-BREAKABLE` (upstream
393        /// `HorzEmbeddedVertBreakable`) rather than `embed-block-top`/`-bottom`
394        /// (`HorzEmbeddedVert`). The breakable one is not laid out as inline
395        /// content at all: the line breaker flushes the current line, splices
396        /// the block's own vertical boxes straight into the vertical list
397        /// (`AlreadyVert`, `lineBreak.ml:809-818`), and starts a fresh line.
398        /// See `break_into_lines`.
399        breakable: bool,
400    },
401    /// An UNBREAKABLE inline frame (`inline-frame-outer`/`-inner`; upstream
402    /// `PHGOuterFrame`/`PHGInnerFrame`) — ATOMIC: contents are pre-fit at
403    /// their natural width (`fit_cell`) and the frame never splits across a
404    /// line break, matching upstream's model. `inline-frame-breakable` is NOT
405    /// this variant — see [`PureHorzBox::InlineFrameMarker`].
406    ///
407    /// `width`/`height`/`depth` are the OUTER dims (padding included, baseline
408    /// unshifted — padding grows the box, upstream lineBreak.ml's frame
409    /// metrics). `contents` carry x-offsets from the frame's left edge (pad-L
410    /// already applied), all on the frame's own baseline. `deco` is fired
411    /// lang-side after placement; the writers draw nothing for it here.
412    Frame {
413        width: Length,
414        height: Length,
415        depth: Length,
416        deco: DecoId,
417        contents: Vec<(Length, PureHorzBox)>,
418    },
419    /// A placed block-frame marker (`VertBox::FrameStart`/`FrameEnd` after
420    /// page breaking) — zero-width, renders nothing (writers' wildcard arm),
421    /// read back by `fire_hooks` only.
422    FrameMarker { id: DecoId, end: bool },
423    /// One boundary of an inline BREAKABLE frame (`inline-frame-breakable`;
424    /// upstream `HorzFrameBreakable`) — the horizontal twin of
425    /// [`PureHorzBox::FrameMarker`], and for the same reason.
426    ///
427    /// Upstream keeps a breakable frame TRANSPARENT to the paragraph breaker:
428    /// `LBFrameBreakable` threads the enclosing width map straight through its
429    /// contents (`lineBreak.ml:1094`), so glue and discretionaries *inside* the
430    /// frame are ordinary DP nodes of the enclosing paragraph, and `cut`
431    /// (`:824`) re-frames the resulting fragments one line at a time
432    /// (`append_framed_lines`), firing `decoS` for an unbroken frame and
433    /// `decoH`/`decoM`/`decoT` per fragment for a broken one.
434    ///
435    /// This port's breaker is a flat index DP over one `Vec<PureHorzBox>`, so
436    /// the same transparency is spelled the other way round: the primitive
437    /// SPLICES the frame's contents into the paragraph stream (padding-L and
438    /// -R as `FixedEmpty`, upstream's `append_horz_padding`) and brackets them
439    /// with this zero-width marker pair, so the inner boxes simply *are* the
440    /// paragraph's boxes. `fire_hooks` reassembles the fragments by walking
441    /// the markers.
442    ///
443    /// `height`/`depth` are the WHOLE frame's padded content extent
444    /// (`content ± pad`), carried on BOTH markers so that any line holding
445    /// either one reserves the frame's full vertical extent. Upstream instead
446    /// sizes each fragment from its own contents; for an unbroken frame (every
447    /// bundled caller: `\ref`, `\href`, TOC entries — all zero-padding) the two
448    /// agree exactly, and for a broken one this over-reserves a fragment by the
449    /// difference between the frame's tallest content and that fragment's,
450    /// which is zero for uniform text.
451    InlineFrameMarker {
452        id: DecoId,
453        end: bool,
454        height: Length,
455        depth: Length,
456    },
457    /// `add-footnote`'s marker (v0.0.6 `PHGFootnote(imvblst)`,
458    /// `horzBox.ml:283` → `ImHorzFootnote`, `:306`): a zero-width/height/
459    /// depth inline box carrying the footnote's already-assembled block.
460    /// Rides the paragraph like `HookPageBreak` (writers skip it via their
461    /// wildcard arm); `chop_page` (pagebreak.rs) extracts it when the line
462    /// carrying it is COMMITTED to a page, reserves the block's stacked
463    /// height at the page bottom, and bottom-places the block in the same
464    /// column (upstream `pageBreak.ml:131-142` + `handlePdf.ml:400-403`).
465    /// The marker itself stays in the placed line's contents (render-inert)
466    /// — extraction is a read-only scan, unlike upstream's removing
467    /// `embed_page_info` (`pageInfo.ml:47`). Consequence: the block payload
468    /// appears both (inert) inside its referencing line and (rendered) as
469    /// bottom-placed lines — any future exhaustive consumer of a placed
470    /// line's contents must treat this variant as inert or it will
471    /// double-count the body.
472    Footnote { block: Vec<VertBox> },
473    /// An INERT reflow marker for emphasis runs (`\emph`/`\bold` in the
474    /// repo-controlled stdlibs that opt in) and list-bullet fencing,
475    /// emitted by the `inline-mark` primitive. Zero width/height/depth and
476    /// renders nothing, so it contributes zero advance wherever it rides in a
477    /// placed line's `contents`. Read only by the reflow HTML walker (the
478    /// `html-support` branch's `reflow/inline.rs`), which uses
479    /// `EmphStart`/`EmphEnd` to wrap `<em>`/`<strong>` and
480    /// `BulletStart`/`BulletEnd` to suppress the drawn bullet/number glyph run
481    /// (the real marker comes from the `<ul>`/`<ol>` itself).
482    InlineMark(InlineMarkKind),
483}
484
485/// The marker kind a `PureHorzBox::InlineMark` carries. `strong` is chosen AT
486/// THE WRAP SITE (which stdlib command calls `inline-mark` with which tag),
487/// not recovered from the box tree: `\emph` -> `strong: false` (`<em>`),
488/// `\bold`/`\strong` -> `strong: true` (`<strong>`).
489#[derive(Clone, Copy, Debug, PartialEq, Eq)]
490pub enum InlineMarkKind {
491    /// Opens `<em>` (`strong = false`) or `<strong>` (`strong = true`).
492    EmphStart { strong: bool },
493    /// Closes the innermost open emphasis span.
494    EmphEnd,
495    /// Opens a fence around a drawn bullet/number glyph run (`itemize.satyh`'s
496    /// `make-bullet`/`enumerate-item`'s numeral) — the reflow walker drops
497    /// everything between this and the matching `BulletEnd`, since the real
498    /// `<ul>`/`<ol>` marker replaces it.
499    BulletStart,
500    /// Closes the bullet fence.
501    BulletEnd,
502}
503
504/// TeX's forced-break convention: a discretionary penalty this low or
505/// lower means the paragraph breaker must end a line there. `text_to_boxes`
506/// uses this to turn a UAX#14 `Mandatory` boundary (e.g. a literal newline)
507/// into a break the DP cannot skip over (see `linebreak.rs`'s `floor`).
508pub const FORCED_BREAK_PENALTY: i32 = -10_000;
509
510/// The mirror convention at the other extreme: a `Discretionary` carrying this
511/// penalty offers NO break at all — it exists only to render its `no_break`
512/// slot, unbreakably.
513///
514/// This is upstream's `LBPure(lphb)`, the `PreventBreak` arm of
515/// `discretionary_if_breakable` (`convertText.ml:189-190`). A pure glue box is
516/// not a breakpoint but its stretch/shrink still count toward the line's
517/// elasticity (`lineBreak.ml`'s `add_width_all`).
518///
519/// The port's box model has no separate "pure elastic box": a bare
520/// `OuterEmpty` IS a breakpoint (`is_glue`). So `PreventBreak` is modelled as a
521/// `Discretionary` with every break slot empty, its content in `no_break`, and
522/// this penalty — which `is_break_point` reads as "not a candidate", keeping
523/// one code path in `text_to_boxes` for both arms.
524pub const NO_BREAK_PENALTY: i32 = i32::MAX;
525
526impl PureHorzBox {
527    pub fn natural_width(&self) -> Length {
528        match self {
529            PureHorzBox::InnerString { width, .. } => *width,
530            PureHorzBox::OuterEmpty { natural, .. } => *natural,
531            PureHorzBox::OuterFil => Length::ZERO,
532            PureHorzBox::FixedEmpty { width } => *width,
533            PureHorzBox::Image { width, .. } => *width,
534            // Un-taken discretionary: renders as `no_break` (hyphenation —
535            // `linebreak.rs`'s `line_content` handles the taken case, which
536            // never reaches this generic accessor). Empty for
537            // UAX#14-only discretionaries, hence zero then.
538            PureHorzBox::Discretionary { no_break, .. } => no_break
539                .iter()
540                .map(PureHorzBox::natural_width)
541                .fold(Length::ZERO, |acc, w| acc + w),
542            PureHorzBox::Graphics { width, .. } => *width,
543            // Fil semantics: zero natural width, like `OuterFil`.
544            PureHorzBox::GraphicsOuter { .. } => Length::ZERO,
545            PureHorzBox::Math { width, .. } => *width,
546            // `EvHorzHookPageBreak` has width `Length.zero` (pageInfo.ml:42).
547            PureHorzBox::HookPageBreak { .. } => Length::ZERO,
548            PureHorzBox::Tabular(tab) => tab.width,
549            PureHorzBox::EmbeddedBlock { width, .. } => *width,
550            PureHorzBox::Frame { width, .. } => *width,
551            PureHorzBox::FrameMarker { .. } => Length::ZERO,
552            PureHorzBox::InlineFrameMarker { .. } => Length::ZERO,
553            // `ImHorzFootnote` is skipped by every width scan upstream,
554            // lineBreak.ml:1200/1254.
555            PureHorzBox::Footnote { .. } => Length::ZERO,
556            PureHorzBox::InlineMark(_) => Length::ZERO,
557        }
558    }
559
560    /// `false` for every variant except the two glue kinds.
561    pub fn is_glue(&self) -> bool {
562        matches!(
563            self,
564            PureHorzBox::OuterEmpty { .. } | PureHorzBox::OuterFil
565        )
566    }
567
568    /// A legal paragraph-break candidate: glue (today's only breakpoints)
569    /// or a discretionary (UAX#14/hyphenation break points). CJK text has
570    /// no glue at all, so discretionaries are its *only* break candidates.
571    ///
572    /// EXCEPT a `NO_BREAK_PENALTY` discretionary, which is upstream's
573    /// `LBPure(glue)` in disguise (see that constant): it renders its
574    /// `no_break` slot and offers no edge.
575    pub fn is_break_point(&self) -> bool {
576        match self {
577            PureHorzBox::Discretionary { penalty, .. } => *penalty != NO_BREAK_PENALTY,
578            _ => self.is_glue(),
579        }
580    }
581
582    /// The break's own penalty (TeX's discretionary/`\penalty`
583    /// convention): 0 for glue (no preference either way), a
584    /// discretionary's own `penalty` otherwise.
585    pub(crate) fn break_penalty(&self) -> i32 {
586        match self {
587            PureHorzBox::Discretionary { penalty, .. } => *penalty,
588            _ => 0,
589        }
590    }
591
592    /// Whether breaking here is not just legal but mandatory
593    /// (`penalty <= FORCED_BREAK_PENALTY`).
594    pub(crate) fn is_forced_break(&self) -> bool {
595        self.break_penalty() <= FORCED_BREAK_PENALTY
596    }
597}
598
599/// `horz_box`: the wrapper stays even though `Pure` is its only variant so
600/// far, keeping line-break input the shape lineBreak.ml expects.
601#[derive(Clone, Debug, PartialEq)]
602pub enum HorzBox {
603    Pure(PureHorzBox),
604}