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 /// Immediately precedes the `pre_break` slot the LINE BREAKER spliced onto
503 /// a line it chose to end here — the hyphen that prints before the break.
504 ///
505 /// It exists so a reflowing renderer can undo the hyphenation exactly. The
506 /// splice produces an ordinary `InnerString`, indistinguishable from a
507 /// hyphen the author typed, so rejoining the lines had to guess from the
508 /// shape of the text — and the guess deleted real hyphens: a paragraph
509 /// wrapping at `code-printer` came out as `codeprinter`. Emitted by
510 /// `linebreak::line_content`, zero-width and rendered by nothing, exactly
511 /// like the other marks here.
512 BreakHyphen,
513}
514
515/// TeX's forced-break convention: a discretionary penalty this low or
516/// lower means the paragraph breaker must end a line there. `text_to_boxes`
517/// uses this to turn a UAX#14 `Mandatory` boundary (e.g. a literal newline)
518/// into a break the DP cannot skip over (see `linebreak.rs`'s `floor`).
519pub const FORCED_BREAK_PENALTY: i32 = -10_000;
520
521/// The mirror convention at the other extreme: a `Discretionary` carrying this
522/// penalty offers NO break at all — it exists only to render its `no_break`
523/// slot, unbreakably.
524///
525/// This is upstream's `LBPure(lphb)`, the `PreventBreak` arm of
526/// `discretionary_if_breakable` (`convertText.ml:189-190`). A pure glue box is
527/// not a breakpoint but its stretch/shrink still count toward the line's
528/// elasticity (`lineBreak.ml`'s `add_width_all`).
529///
530/// The port's box model has no separate "pure elastic box": a bare
531/// `OuterEmpty` IS a breakpoint (`is_glue`). So `PreventBreak` is modelled as a
532/// `Discretionary` with every break slot empty, its content in `no_break`, and
533/// this penalty — which `is_break_point` reads as "not a candidate", keeping
534/// one code path in `text_to_boxes` for both arms.
535pub const NO_BREAK_PENALTY: i32 = i32::MAX;
536
537impl PureHorzBox {
538 pub fn natural_width(&self) -> Length {
539 match self {
540 PureHorzBox::InnerString { width, .. } => *width,
541 PureHorzBox::OuterEmpty { natural, .. } => *natural,
542 PureHorzBox::OuterFil => Length::ZERO,
543 PureHorzBox::FixedEmpty { width } => *width,
544 PureHorzBox::Image { width, .. } => *width,
545 // Un-taken discretionary: renders as `no_break` (hyphenation —
546 // `linebreak.rs`'s `line_content` handles the taken case, which
547 // never reaches this generic accessor). Empty for
548 // UAX#14-only discretionaries, hence zero then.
549 PureHorzBox::Discretionary { no_break, .. } => no_break
550 .iter()
551 .map(PureHorzBox::natural_width)
552 .fold(Length::ZERO, |acc, w| acc + w),
553 PureHorzBox::Graphics { width, .. } => *width,
554 // Fil semantics: zero natural width, like `OuterFil`.
555 PureHorzBox::GraphicsOuter { .. } => Length::ZERO,
556 PureHorzBox::Math { width, .. } => *width,
557 // `EvHorzHookPageBreak` has width `Length.zero` (pageInfo.ml:42).
558 PureHorzBox::HookPageBreak { .. } => Length::ZERO,
559 PureHorzBox::Tabular(tab) => tab.width,
560 PureHorzBox::EmbeddedBlock { width, .. } => *width,
561 PureHorzBox::Frame { width, .. } => *width,
562 PureHorzBox::FrameMarker { .. } => Length::ZERO,
563 PureHorzBox::InlineFrameMarker { .. } => Length::ZERO,
564 // `ImHorzFootnote` is skipped by every width scan upstream,
565 // lineBreak.ml:1200/1254.
566 PureHorzBox::Footnote { .. } => Length::ZERO,
567 PureHorzBox::InlineMark(_) => Length::ZERO,
568 }
569 }
570
571 /// `false` for every variant except the two glue kinds.
572 pub fn is_glue(&self) -> bool {
573 matches!(
574 self,
575 PureHorzBox::OuterEmpty { .. } | PureHorzBox::OuterFil
576 )
577 }
578
579 /// A legal paragraph-break candidate: glue (today's only breakpoints)
580 /// or a discretionary (UAX#14/hyphenation break points). CJK text has
581 /// no glue at all, so discretionaries are its *only* break candidates.
582 ///
583 /// EXCEPT a `NO_BREAK_PENALTY` discretionary, which is upstream's
584 /// `LBPure(glue)` in disguise (see that constant): it renders its
585 /// `no_break` slot and offers no edge.
586 pub fn is_break_point(&self) -> bool {
587 match self {
588 PureHorzBox::Discretionary { penalty, .. } => *penalty != NO_BREAK_PENALTY,
589 _ => self.is_glue(),
590 }
591 }
592
593 /// The break's own penalty (TeX's discretionary/`\penalty`
594 /// convention): 0 for glue (no preference either way), a
595 /// discretionary's own `penalty` otherwise.
596 pub(crate) fn break_penalty(&self) -> i32 {
597 match self {
598 PureHorzBox::Discretionary { penalty, .. } => *penalty,
599 _ => 0,
600 }
601 }
602
603 /// Whether breaking here is not just legal but mandatory
604 /// (`penalty <= FORCED_BREAK_PENALTY`).
605 pub(crate) fn is_forced_break(&self) -> bool {
606 self.break_penalty() <= FORCED_BREAK_PENALTY
607 }
608}
609
610/// `horz_box`: the wrapper stays even though `Pure` is its only variant so
611/// far, keeping line-break input the shape lineBreak.ml expects.
612#[derive(Clone, Debug, PartialEq)]
613pub enum HorzBox {
614 Pure(PureHorzBox),
615}