oxedyne/fe2o3/fe2o3_austenite/src/table.rs
43.6 KiB, 310 runs
created by r1870400018:36039, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Tables: a ruled grid of cells, laid out as one keep box in the vertical flow. |
| 2 | //! |
| 3 | //! A table is authored as [`Table`] -- rows of [`Cell`]s -- and [`lower`] turns it into a single |
| 4 | //! [`Node::VBox`], so the driver's greedy page breaker moves the whole table to the next page when it |
| 5 | //! will not fit where it stands. Column widths are measured from the cell text and, when the natural |
| 6 | //! widths overrun the measure, shrunk proportionally; a cell too wide for its column is wrapped with |
| 7 | //! [`break_paragraph`](crate::linebreak::break_paragraph) at the column width, exactly as a paragraph |
| 8 | //! is wrapped at the measure. |
| 9 | //! |
| 10 | //! One fact a reader could not derive, and the reason the layout looks the way it does. The driver |
| 11 | //! renders a box *nested inside an HBox* as a placeholder rectangle -- only leaves (glyph runs and |
| 12 | //! rules) draw as ink there -- so a cell's glyphs cannot be a nested box. A row is therefore |
| 13 | //! decomposed into horizontal *bands*: each band is one HBox holding, positioned by glue, the |
| 14 | //! vertical rules at the column boundaries and one wrapped line from each cell. Bands stack with no |
| 15 | //! gap, so the per-band rule segments tile into continuous column rules, and a cell that wraps to |
| 16 | //! several lines simply contributes to several bands. The whole grid is thus leaves in HBoxes stacked |
| 17 | //! in one VBox, which is the only shape the driver draws as real glyphs throughout. |
| 18 | |
| 19 | use crate::bib::Bibliography; |
| 20 | use crate::doc::{ |
| 21 | ClaimGather, |
| 22 | IndexGather, |
| 23 | Segment, |
| 24 | build_pieces, |
| 25 | }; |
| 26 | use crate::page::PageGeometry; |
| 27 | use crate::theme::Theme; |
| 28 | use crate::font::ShapedText; |
| 29 | use crate::ir::{ |
| 30 | BoxNode, |
| 31 | Dims, |
| 32 | DrawOp, |
| 33 | Glue, |
| 34 | Graphic, |
| 35 | Leaf, |
| 36 | Node, |
| 37 | Sp, |
| 38 | }; |
| 39 | use crate::linebreak::{ |
| 40 | Piece, |
| 41 | break_paragraph_pieces, |
| 42 | }; |
| 43 | |
| 44 | use oxedyne_fe2o3_core::prelude::*; |
| 45 | use oxedyne_fe2o3_font::{ |
| 46 | face::Role, |
| 47 | set::FontSet, |
| 48 | shape::{ |
| 49 | Dir, |
| 50 | Feature, |
| 51 | }, |
| 52 | }; |
| 53 | use oxedyne_fe2o3_graphics::{ |
| 54 | colour::Rgba, |
| 55 | path::{ |
| 56 | Bounds, |
| 57 | Path, |
| 58 | }, |
| 59 | }; |
| 60 | |
| 61 | use std::collections::{ |
| 62 | HashMap, |
| 63 | HashSet, |
| 64 | }; |
| 65 | use std::sync::Arc; |
| 66 | |
| 67 | /// How a cell's text sits within its column width. |
| 68 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 69 | pub enum Align { |
| 70 | Left, |
| 71 | Centre, |
| 72 | Right, |
| 73 | } |
| 74 | |
| 75 | /// An image mark stacked beneath a cell's text: the meta page's "Made with AI" chip and its caption. The |
| 76 | /// chip is a clickable link to the declaration's scheme page (`url`); the PDF writer draws a link |
| 77 | /// annotation over it, while the SVG writer sets it as a plain image. Only the image is the link, the |
| 78 | /// caption beside it plain, matching the template. The mark occupies its own band under the cell's last |
| 79 | /// text line, the image seated left with the caption beside it. |
| 80 | #[derive(Clone, Debug)] |
| 81 | pub struct CellMark { |
| 82 | pub path: String, // the mark image, resolved against the image base directory |
| 83 | pub height: Sp, // the drawn image height |
| 84 | pub words: String, // the caption set beside the image |
| 85 | pub url: Option<String>, // the declaration scheme page the chip links to |
| 86 | } |
| 87 | |
| 88 | /// One cell: its inline content, and how that content aligns in the column. A cell carries a run of |
| 89 | /// [`Segment`]s -- a bold header, an italic word, a superscript dagger or an in-cell maths span each set |
| 90 | /// with its own face -- broken to the column width exactly as a rich paragraph is broken to the measure. |
| 91 | #[derive(Clone, Debug)] |
| 92 | pub struct Cell { |
| 93 | pub content: Vec<Segment>, |
| 94 | pub align: Align, |
| 95 | pub mark: Option<CellMark>, // an image mark stacked beneath the text, the meta page's AI chip |
| 96 | } |
| 97 | |
| 98 | impl Cell { |
| 99 | pub fn new<S: Into<String>>(text: S) -> Self { |
| 100 | Self { content: vec![Segment::text(text)], align: Align::Left, mark: None } |
| 101 | } |
| 102 | |
| 103 | pub fn aligned<S: Into<String>>(text: S, align: Align) -> Self { |
| 104 | Self { content: vec![Segment::text(text)], align, mark: None } |
| 105 | } |
| 106 | |
| 107 | /// A cell carrying a run of rich segments -- the form the reader builds from a Typst cell's markup. |
| 108 | pub fn rich(content: Vec<Segment>, align: Align) -> Self { |
| 109 | Self { content, align, mark: None } |
| 110 | } |
| 111 | |
| 112 | /// A cell carrying a run of rich segments and an image mark stacked beneath them -- the meta page's |
| 113 | /// author cell, whose name is followed by the "Made with AI" chip and its caption. |
| 114 | pub fn rich_with_mark(content: Vec<Segment>, align: Align, mark: CellMark) -> Self { |
| 115 | Self { content, align, mark: Some(mark) } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// One row of cells. A row shorter than the widest row is padded with empty cells when the table is |
| 120 | /// laid out, so a ragged authoring is legal. |
| 121 | #[derive(Clone, Debug)] |
| 122 | pub struct Row { |
| 123 | pub cells: Vec<Cell>, |
| 124 | } |
| 125 | |
| 126 | impl Row { |
| 127 | pub fn new(cells: Vec<Cell>) -> Self { |
| 128 | Self { cells } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /// A table: its rows, and whether the first row is a header (set bold, with a heavier rule beneath |
| 133 | /// it). Spanning cells and a caption with a "Table N" number are later additions. |
| 134 | #[derive(Clone, Debug)] |
| 135 | pub struct Table { |
| 136 | pub rows: Vec<Row>, |
| 137 | pub header: bool, |
| 138 | pub weights: Vec<f64>, // per-column fractional (`fr`) weight; 0.0 for a content-sized column, empty for none |
| 139 | pub text_size: Option<Sp>, // the `text(size: Npt)` wrapper's size, so a small-set table sets small; body size when none |
| 140 | pub inset: Option<Sp>, // the `inset:` cell padding, overriding the style default when set |
| 141 | pub breakable: bool, // set the table as per-row keep boxes so a tall one (a glossary) paginates |
| 142 | } |
| 143 | |
| 144 | impl Table { |
| 145 | pub fn new(header: bool, rows: Vec<Row>) -> Self { |
| 146 | Self { rows, header, weights: Vec::new(), text_size: None, inset: None, breakable: false } |
| 147 | } |
| 148 | |
| 149 | /// A table with declared fractional column weights, reproducing Typst's `columns: (2fr, 5fr, ...)` |
| 150 | /// sizing; a `0.0` weight leaves that column sized to its content. |
| 151 | pub fn with_weights(header: bool, rows: Vec<Row>, weights: Vec<f64>) -> Self { |
| 152 | Self { rows, header, weights, text_size: None, inset: None, breakable: false } |
| 153 | } |
| 154 | |
| 155 | /// A grid of string rows, every cell left-aligned; the first row a header when `header`. |
| 156 | pub fn grid(header: bool, rows: Vec<Vec<&str>>) -> Self { |
| 157 | let rows = rows.into_iter() |
| 158 | .map(|r| Row::new(r.into_iter().map(Cell::new).collect())) |
| 159 | .collect(); |
| 160 | Self { rows, header, weights: Vec::new(), text_size: None, inset: None, breakable: false } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | /// One wrapped line of a cell: the shaped leaves and justifying glue as `break_paragraph` set them, |
| 165 | /// with the natural extent kept so a band can align and stack them. |
| 166 | struct CellLine { |
| 167 | children: Vec<Node>, |
| 168 | width: Sp, |
| 169 | height: Sp, |
| 170 | depth: Sp, |
| 171 | } |
| 172 | |
| 173 | /// Builds a table's rows as separate node groups, each carrying one row's rules, header wash and text |
| 174 | /// bands together with the row's own height. The first group opens with the top frame rule. [`lower`] |
| 175 | /// stacks every group into one keep box (a plain table moves whole); [`lower_rows`] sets each group as its |
| 176 | /// own keep box so a tall table (a glossary) paginates between rows. The measure is the width a table |
| 177 | /// spanning the full text block may use; a table whose natural columns are narrower is set narrower, flush |
| 178 | /// left. The returned `Sp` is the table's laid width, shared by both callers. |
| 179 | #[allow(clippy::too_many_arguments)] |
| 180 | fn table_row_groups( |
| 181 | fonts: Arc<FontSet>, |
| 182 | geom: PageGeometry, |
| 183 | style: &Theme, |
| 184 | measure: Sp, |
| 185 | table: &Table, |
| 186 | foot_no: &mut u32, |
| 187 | ref_no: &mut u32, |
| 188 | margin_no: &mut u32, |
| 189 | seen: &mut HashSet<String>, |
| 190 | idx: &mut IndexGather, |
| 191 | claim: &mut ClaimGather, |
| 192 | bib: Option<&Bibliography>, |
| 193 | refs: &HashMap<String, String>, |
| 194 | ) |
| 195 | -> Outcome<(Vec<(Vec<Node>, Sp)>, Sp)> |
| 196 | { |
| 197 | // A `text(size: Npt)` wrapper sets the whole table at its reduced size (the books' claim tables set at |
| 198 | // 7 pt), and an explicit `inset:` overrides the cell padding on both axes; the interline gap within a |
| 199 | // cell scales with the text so a small table sets tight. The rules and header wash keep the style's. |
| 200 | let size = table.text_size.unwrap_or(style.text.body_size); |
| 201 | let scale = size.raw() as f64 / style.text.body_size.raw().max(1) as f64; |
| 202 | let pad_x = table.inset.unwrap_or(style.table.cell_pad_x); |
| 203 | let pad_y = table.inset.unwrap_or(style.table.cell_pad_y); |
| 204 | let line_gap = if table.text_size.is_some() { |
| 205 | Sp::from_pt(style.table.line_gap.to_pt() * scale) |
| 206 | } else { |
| 207 | style.table.line_gap |
| 208 | }; |
| 209 | let cell_leading = Sp::from_pt(size.to_pt() * 1.2); // dropped by `break_cell`, but a sane interline base |
| 210 | // A breakable table (the glossary) measures its rows to Typst's cap-height/baseline extent, so a |
| 211 | // one-line row is the cap-to-baseline height rather than the taller ascender-to-descender box -- the |
| 212 | // ~4.4pt-per-row overshoot the earlier `cap: None` left. A plain table keeps its own inset-based row |
| 213 | // heights (`None`), so every non-breakable table renders byte-identically to before. |
| 214 | let cell_cap = if table.breakable { Some(crate::doc::cap_edge(style, size)) } else { None }; |
| 215 | let rows = &table.rows; |
| 216 | if rows.is_empty() { |
| 217 | return Err(err!("A table needs at least one row."; Input, Invalid, Missing)); |
| 218 | } |
| 219 | let ncols = rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); |
| 220 | if ncols == 0 { |
| 221 | return Err(err!("A table needs at least one column."; Input, Invalid, Missing)); |
| 222 | } |
| 223 | |
| 224 | // The vertical rules: a heavier pen frames the grid, a lighter one divides the columns. Their |
| 225 | // widths take real horizontal space, so they are budgeted before the columns are sized. |
| 226 | let mut tv = vec![style.table.rule_thin; ncols + 1]; |
| 227 | tv[0] = style.table.rule_thick; |
| 228 | tv[ncols] = style.table.rule_thick; |
| 229 | let vrule_total: i32 = tv.iter().map(|s| s.raw()).sum(); |
| 230 | |
| 231 | // The text width left for the columns after the padding either side of every cell and the rules |
| 232 | // between them are taken out of the measure. |
| 233 | let pad2 = pad_x.raw() * 2 * ncols as i32; |
| 234 | let available = measure.raw() - pad2 - vrule_total; |
| 235 | if available <= 0 { |
| 236 | return Err(err!( |
| 237 | "A table of {} columns leaves no width for text within the measure of {} sp; \ |
| 238 | reduce the columns or the padding.", ncols, measure.raw(); Input, Invalid, TooBig)); |
| 239 | } |
| 240 | |
| 241 | // Each cell's inline content is built once into the pieces the line breaker weaves -- a run of shaped |
| 242 | // text in its face, a maths cluster, a superscript mark -- and reused for measuring the columns and for |
| 243 | // the final wrap, so a cell shapes its faces only once. The base role per row is bold in a header row, |
| 244 | // so a plain header label still sets bold, and body elsewhere. |
| 245 | let (piece_grid, bases) = res!(build_grid( |
| 246 | fonts.clone(), geom, style, table, ncols, |
| 247 | foot_no, ref_no, margin_no, seen, idx, claim, bib, refs)); |
| 248 | let colwidth = res!(size_columns( |
| 249 | fonts.clone(), size, &piece_grid, &bases, ncols, available, &table.weights)); |
| 250 | |
| 251 | // Column boundary positions, the running x of each vertical rule and each cell's text left. |
| 252 | let mut vrule_left = vec![Sp::ZERO; ncols + 1]; |
| 253 | let mut cx = Sp::ZERO; |
| 254 | for b in 0..=ncols { |
| 255 | vrule_left[b] = cx; |
| 256 | cx = cx + tv[b]; |
| 257 | if b < ncols { |
| 258 | cx = cx + Sp(pad_x.raw() * 2) + colwidth[b]; |
| 259 | } |
| 260 | } |
| 261 | let table_width = cx; |
| 262 | let mut content_left = vec![Sp::ZERO; ncols]; |
| 263 | for c in 0..ncols { |
| 264 | content_left[c] = vrule_left[c] + tv[c] + pad_x; |
| 265 | } |
| 266 | |
| 267 | // Wrap every cell to its column, and note the tallest stack of lines in each row. |
| 268 | let mut grid: Vec<Vec<Vec<CellLine>>> = Vec::with_capacity(rows.len()); |
| 269 | let mut nbands: Vec<usize> = Vec::with_capacity(rows.len()); |
| 270 | for r in 0..rows.len() { |
| 271 | let mut cells = Vec::with_capacity(ncols); |
| 272 | let mut bands = 0usize; |
| 273 | for c in 0..ncols { |
| 274 | let mut lines = res!(break_cell( |
| 275 | fonts.clone(), bases[r], size, &piece_grid[r][c], colwidth[c], cell_leading, cell_cap)); |
| 276 | // An image mark stacked beneath the cell's text takes its own band under the last line. A mark |
| 277 | // whose image will not load leaves the cell to its text alone, as a title logo degrades. |
| 278 | if let Some(mark) = rows[r].cells.get(c).and_then(|cell| cell.mark.as_ref()) { |
| 279 | if let Ok(line) = mark_line(fonts.clone(), size, mark) { |
| 280 | lines.push(line); |
| 281 | } |
| 282 | } |
| 283 | bands = bands.max(lines.len()); |
| 284 | cells.push(lines); |
| 285 | } |
| 286 | grid.push(cells); |
| 287 | nbands.push(bands.max(1)); // an all-empty row still occupies one line band |
| 288 | } |
| 289 | |
| 290 | // A fallback line extent for a band no cell fills, so an empty row keeps a sensible height. |
| 291 | let sample = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, size, "Ag")); |
| 292 | let default_v = sample.dims().height + sample.dims().depth; |
| 293 | |
| 294 | let mut groups: Vec<(Vec<Node>, Sp)> = Vec::with_capacity(rows.len() + 1); |
| 295 | let mut cur: Vec<Node> = Vec::new(); |
| 296 | let mut cur_h = Sp::ZERO; |
| 297 | |
| 298 | // The top frame opens the first row's group. |
| 299 | push_hrule(&mut cur, &mut cur_h, table_width, style.table.rule_thick); |
| 300 | |
| 301 | for r in 0..rows.len() { |
| 302 | // A header row carries a grey wash behind every one of its bands, drawn before the rules and text |
| 303 | // so they sit over it; a body row has none. |
| 304 | let fill = if table.header && r == 0 { Some(style.colours.header_fill) } else { None }; |
| 305 | |
| 306 | // The top padding of the row: a rules-only band, so the first line's baseline clears the rule. |
| 307 | let empty: Vec<Option<&CellLine>> = vec![None; ncols]; |
| 308 | let flush: Vec<Align> = vec![Align::Left; ncols]; |
| 309 | let pad_band = build_band( |
| 310 | ncols, &vrule_left, &tv, &content_left, &colwidth, |
| 311 | pad_y, table_width, &empty, &flush, fill); |
| 312 | cur.push(pad_band); |
| 313 | cur_h += pad_y; |
| 314 | |
| 315 | let bands = nbands[r]; |
| 316 | for k in 0..bands { |
| 317 | // The band height is the tallest line at this level, plus a trailing gap -- interline |
| 318 | // leading between lines, the bottom cell padding after the last. |
| 319 | let mut lh = Sp::ZERO; |
| 320 | for c in 0..ncols { |
| 321 | if let Some(cl) = grid[r][c].get(k) { |
| 322 | let v = cl.height + cl.depth; |
| 323 | if v > lh { lh = v; } |
| 324 | } |
| 325 | } |
| 326 | if lh.raw() == 0 { lh = default_v; } |
| 327 | let trailing = if k + 1 < bands { line_gap } else { pad_y }; |
| 328 | let bh = lh + trailing; |
| 329 | |
| 330 | let mut opt: Vec<Option<&CellLine>> = Vec::with_capacity(ncols); |
| 331 | let mut aligns: Vec<Align> = Vec::with_capacity(ncols); |
| 332 | for c in 0..ncols { |
| 333 | opt.push(grid[r][c].get(k)); |
| 334 | aligns.push(rows[r].cells.get(c).map_or(Align::Left, |cell| cell.align)); |
| 335 | } |
| 336 | |
| 337 | let hb = build_band( |
| 338 | ncols, &vrule_left, &tv, &content_left, &colwidth, |
| 339 | bh, table_width, &opt, &aligns, fill); |
| 340 | cur.push(hb); |
| 341 | cur_h += bh; |
| 342 | } |
| 343 | |
| 344 | // The rule under the row: heavy beneath a header and at the very foot, light between body rows. |
| 345 | let th = if table.header && r == 0 { |
| 346 | style.table.rule_thick |
| 347 | } else if r + 1 == rows.len() { |
| 348 | style.table.rule_thick |
| 349 | } else { |
| 350 | style.table.rule_thin |
| 351 | }; |
| 352 | push_hrule(&mut cur, &mut cur_h, table_width, th); |
| 353 | |
| 354 | // Close this row's group; the next row opens a fresh one. |
| 355 | groups.push((std::mem::take(&mut cur), std::mem::replace(&mut cur_h, Sp::ZERO))); |
| 356 | } |
| 357 | |
| 358 | Ok((groups, table_width)) |
| 359 | } |
| 360 | |
| 361 | /// Lowers a table to one keep box: every row group stacked into a single [`Node::VBox`], so the driver's |
| 362 | /// greedy page breaker moves the whole table to the next page when it will not fit. This is the shape every |
| 363 | /// plain table sets in. |
| 364 | #[allow(clippy::too_many_arguments)] |
| 365 | pub fn lower( |
| 366 | fonts: Arc<FontSet>, |
| 367 | geom: PageGeometry, |
| 368 | style: &Theme, |
| 369 | measure: Sp, |
| 370 | table: &Table, |
| 371 | foot_no: &mut u32, |
| 372 | ref_no: &mut u32, |
| 373 | margin_no: &mut u32, |
| 374 | seen: &mut HashSet<String>, |
| 375 | idx: &mut IndexGather, |
| 376 | claim: &mut ClaimGather, |
| 377 | bib: Option<&Bibliography>, |
| 378 | refs: &HashMap<String, String>, |
| 379 | ) |
| 380 | -> Outcome<Node> |
| 381 | { |
| 382 | let (groups, table_width) = res!(table_row_groups( |
| 383 | fonts, geom, style, measure, table, |
| 384 | foot_no, ref_no, margin_no, seen, idx, claim, bib, refs)); |
| 385 | let mut children: Vec<Node> = Vec::new(); |
| 386 | let mut total_h = Sp::ZERO; |
| 387 | for (nodes, h) in groups { |
| 388 | children.extend(nodes); |
| 389 | total_h += h; |
| 390 | } |
| 391 | Ok(Node::VBox(BoxNode::new(children, Dims::new(table_width, total_h, Sp::ZERO)))) |
| 392 | } |
| 393 | |
| 394 | /// Lowers a table to one keep box per row, returned as sibling nodes, so the driver paginates between rows |
| 395 | /// while never splitting a row -- Typst's `block(breakable: true)` table with unbreakable cells. Used for a |
| 396 | /// tall back-matter table (the glossary) that must run over many pages rather than clip to one keep box. |
| 397 | /// |
| 398 | /// A zero-height glue is woven between the row boxes. The greedy page breaker treats a glue after a box as |
| 399 | /// its one legal breakpoint (see [`driver::compose`](crate::driver)); without it a run of adjacent boxes is |
| 400 | /// one welded atom, so the whole table would be measured and placed as a single unbreakable unit and every |
| 401 | /// row past the first page would overflow off the bottom and clip. The glue is the breakpoint that lets the |
| 402 | /// breaker paginate between rows; being zero it adds no space, and the leading glue at a page top is |
| 403 | /// discarded like any other. |
| 404 | /// |
| 405 | /// When the table has a header row, that row's box is emitted first, then a [`Node::RepeatHead`] armed with |
| 406 | /// a clone of it, and a matching `RepeatHead(None)` is emitted after the last row. The driver stamps the |
| 407 | /// armed header at the top of every fresh page the body rows spill onto, so the column heads repeat down a |
| 408 | /// multi-page run -- Typst's repeated `table.header`. The first-page header is the emitted box, not the |
| 409 | /// repeat, so it is never doubled there. |
| 410 | #[allow(clippy::too_many_arguments)] |
| 411 | pub fn lower_rows( |
| 412 | fonts: Arc<FontSet>, |
| 413 | geom: PageGeometry, |
| 414 | style: &Theme, |
| 415 | measure: Sp, |
| 416 | table: &Table, |
| 417 | foot_no: &mut u32, |
| 418 | ref_no: &mut u32, |
| 419 | margin_no: &mut u32, |
| 420 | seen: &mut HashSet<String>, |
| 421 | idx: &mut IndexGather, |
| 422 | claim: &mut ClaimGather, |
| 423 | bib: Option<&Bibliography>, |
| 424 | refs: &HashMap<String, String>, |
| 425 | ) |
| 426 | -> Outcome<Vec<Node>> |
| 427 | { |
| 428 | let (groups, table_width) = res!(table_row_groups( |
| 429 | fonts, geom, style, measure, table, |
| 430 | foot_no, ref_no, margin_no, seen, idx, claim, bib, refs)); |
| 431 | let mut out: Vec<Node> = Vec::with_capacity(groups.len() * 2 + 2); |
| 432 | let mut groups = groups.into_iter(); |
| 433 | |
| 434 | // The header row's group, when the table has one: emitted as the first box, then armed as the repeat so |
| 435 | // every continuation page reprints it. Without a header the loop below just weaves the rows as before. |
| 436 | let mut armed = false; |
| 437 | if table.header { |
| 438 | if let Some((nodes, h)) = groups.next() { |
| 439 | let header = BoxNode::new(nodes, Dims::new(table_width, h, Sp::ZERO)); |
| 440 | out.push(Node::VBox(header.clone())); |
| 441 | out.push(Node::RepeatHead(Some(Box::new(header)))); |
| 442 | armed = true; |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | for (nodes, h) in groups { |
| 447 | if !out.is_empty() { |
| 448 | out.push(Node::Glue(Glue::fixed(Sp::ZERO))); // the legal breakpoint between rows |
| 449 | } |
| 450 | out.push(Node::VBox(BoxNode::new(nodes, Dims::new(table_width, h, Sp::ZERO)))); |
| 451 | } |
| 452 | |
| 453 | // Disarm the repeat after the last row, so material below the table (should any follow) gets no header. |
| 454 | if armed { |
| 455 | out.push(Node::RepeatHead(None)); |
| 456 | } |
| 457 | Ok(out) |
| 458 | } |
| 459 | |
| 460 | /// Builds a cell's image mark as one line: the image seated at the left, then a gap, then the caption |
| 461 | /// beside it. The image is drawn at the mark's fixed height and the caption set a little smaller; the |
| 462 | /// caption is lowered so it sits about the image's vertical middle rather than clinging to its top, since |
| 463 | /// a band tops-aligns its leaves. The line's extent is the image height, so its band clears the chip. |
| 464 | fn mark_line( |
| 465 | fonts: Arc<FontSet>, |
| 466 | size: Sp, |
| 467 | mark: &CellMark, |
| 468 | ) |
| 469 | -> Outcome<CellLine> |
| 470 | { |
| 471 | let mut graphic = res!(crate::doc::image_at_height(&fonts, &mark.path, mark.height.to_pt())); |
| 472 | // The chip carries the declaration's scheme link; the PDF writer draws an annotation over its box. |
| 473 | if let Some(url) = &mark.url { |
| 474 | graphic = graphic.with_link(url.clone()); |
| 475 | } |
| 476 | let img = Leaf::graphic(graphic); |
| 477 | let img_w = img.dims.width; |
| 478 | let img_h = img.dims.height; |
| 479 | |
| 480 | let cap_size = Sp(size.raw() * 82 / 100); |
| 481 | let shaped = res!(ShapedText::new(fonts, Role::Body, Dir::Ltr, cap_size, &mark.words)); |
| 482 | let cd = shaped.dims(); |
| 483 | let mut words = Leaf::text(shaped); |
| 484 | // Lower the caption to about the image's vertical middle; the band tops-aligns its leaves, so without |
| 485 | // the shift the words would cling to the chip's top edge. |
| 486 | let drop = (img_h.raw() - cd.height.raw()).max(0) / 2; |
| 487 | words.shift = Sp(drop); |
| 488 | |
| 489 | let gap = Sp::from_pt(6.0); |
| 490 | let children = vec![Node::Leaf(img), Node::Glue(Glue::fixed(gap)), Node::Leaf(words)]; |
| 491 | let width = img_w + gap + cd.width; |
| 492 | Ok(CellLine { children, width, height: img_h, depth: Sp::ZERO }) |
| 493 | } |
| 494 | |
| 495 | /// The base role a row's plain text sets in: bold for a header row, the body face otherwise. Authored |
| 496 | /// emphasis within a cell keeps its own face over this base. |
| 497 | fn base_role(table: &Table, r: usize) -> Role { |
| 498 | if table.header && r == 0 { Role::Bold } else { Role::Body } |
| 499 | } |
| 500 | |
| 501 | /// Builds every cell's pieces once, and the base role of each row. A missing cell (a ragged row shorter |
| 502 | /// than the widest) gives an empty piece list, so the column simply carries nothing there. Each cell's |
| 503 | /// segments run through the body's own [`build_pieces`](crate::doc::build_pieces), so a citation renders to |
| 504 | /// its author-year text against the bibliography, an index marker weaves its zero-width gather anchor, and a |
| 505 | /// `#claim-refs`/`#claim-label` records its reverse-index anchor -- exactly as a body paragraph does, rather |
| 506 | /// than being dropped. The document-order counters (`foot_no`, `ref_no`, `margin_no`, the glossary first-use |
| 507 | /// `seen`), the index and claim gathers and the bibliography are threaded in so a cell counts and gathers in |
| 508 | /// step with the surrounding flow. |
| 509 | #[allow(clippy::too_many_arguments)] |
| 510 | fn build_grid( |
| 511 | fonts: Arc<FontSet>, |
| 512 | geom: PageGeometry, |
| 513 | style: &Theme, |
| 514 | table: &Table, |
| 515 | ncols: usize, |
| 516 | foot_no: &mut u32, |
| 517 | ref_no: &mut u32, |
| 518 | margin_no: &mut u32, |
| 519 | seen: &mut HashSet<String>, |
| 520 | idx: &mut IndexGather, |
| 521 | claim: &mut ClaimGather, |
| 522 | bib: Option<&Bibliography>, |
| 523 | refs: &HashMap<String, String>, |
| 524 | ) |
| 525 | -> Outcome<(Vec<Vec<Vec<Piece>>>, Vec<Role>)> |
| 526 | { |
| 527 | let mut grid: Vec<Vec<Vec<Piece>>> = Vec::with_capacity(table.rows.len()); |
| 528 | let mut bases: Vec<Role> = Vec::with_capacity(table.rows.len()); |
| 529 | for (r, row) in table.rows.iter().enumerate() { |
| 530 | let base = base_role(table, r); |
| 531 | bases.push(base); |
| 532 | let mut cols = Vec::with_capacity(ncols); |
| 533 | for c in 0..ncols { |
| 534 | let pieces = match row.cells.get(c) { |
| 535 | Some(cell) => res!(build_pieces( |
| 536 | fonts.clone(), geom, style, &cell.content, base, |
| 537 | foot_no, ref_no, margin_no, seen, idx, claim, bib, refs)), |
| 538 | None => Vec::new(), |
| 539 | }; |
| 540 | cols.push(pieces); |
| 541 | } |
| 542 | grid.push(cols); |
| 543 | } |
| 544 | Ok((grid, bases)) |
| 545 | } |
| 546 | |
| 547 | /// The face an emphasised run takes over a base: bold-italic within a header (whose base is bold), plain |
| 548 | /// italic elsewhere. |
| 549 | pub(crate) fn emph_role(base: Role) -> Role { |
| 550 | if base == Role::Bold { Role::BoldItalic } else { Role::Italic } |
| 551 | } |
| 552 | |
| 553 | /// Assigns each column a text width. Each column asks for its widest cell's natural width; when the |
| 554 | /// columns together fit the available width they keep it (the table sets narrower than the measure), |
| 555 | /// and when they overrun it they are shrunk. The shrink holds every column at no less than its widest |
| 556 | /// single word and shares the remaining width in proportion to how much each wanted above that |
| 557 | /// minimum; if even the minimums overrun, the columns shrink in proportion to their natural widths |
| 558 | /// and an over-long word is left to run under a rule. It does not know which column would most repay |
| 559 | /// extra width, and it cannot span a cell across columns -- both later refinements. |
| 560 | fn size_columns( |
| 561 | fonts: Arc<FontSet>, |
| 562 | size: Sp, |
| 563 | grid: &[Vec<Vec<Piece>>], |
| 564 | bases: &[Role], |
| 565 | ncols: usize, |
| 566 | available: i32, |
| 567 | weights: &[f64], |
| 568 | ) |
| 569 | -> Outcome<Vec<Sp>> |
| 570 | { |
| 571 | let mut natural = vec![0i64; ncols]; |
| 572 | let mut minw = vec![0i64; ncols]; |
| 573 | for (r, row) in grid.iter().enumerate() { |
| 574 | let base = bases[r]; |
| 575 | for c in 0..ncols { |
| 576 | let pieces = &row[c]; |
| 577 | if pieces.is_empty() { |
| 578 | continue; |
| 579 | } |
| 580 | // The natural width is the cell set on one line; the minimum is the widest line once the cell is |
| 581 | // broken as hard as it can be, the least the column can shrink to before a word must protrude. |
| 582 | let nat = res!(measure_cell(fonts.clone(), base, size, pieces, Sp::from_pt(100_000.0))); |
| 583 | natural[c] = natural[c].max(nat.raw() as i64); |
| 584 | let lw = res!(measure_cell(fonts.clone(), base, size, pieces, Sp(1))); |
| 585 | minw[c] = minw[c].max(lw.raw() as i64); |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | let avail = available as i64; |
| 590 | |
| 591 | // A declared `columns: (2fr, 5fr, ...)` track list sizes the columns fractionally, as Typst does: a |
| 592 | // content-sized (`auto`, weight 0) column takes its natural one-line width first, and the `fr` columns |
| 593 | // share the width left over in proportion to their weights. This is what a table authored with an `fr` |
| 594 | // spec expects, and it keeps every column on the oracle's proportions rather than sizing each from its |
| 595 | // own widest cell -- which drifted a column a hair too narrow and left a rich cell overrunning its rule. |
| 596 | if weights.iter().any(|&w| w > 0.0) { |
| 597 | let mut colwidth = vec![Sp::ZERO; ncols]; |
| 598 | let mut fixed = 0i64; |
| 599 | for c in 0..ncols { |
| 600 | if weights.get(c).copied().unwrap_or(0.0) <= 0.0 { |
| 601 | colwidth[c] = Sp(natural[c] as i32); |
| 602 | fixed += natural[c]; |
| 603 | } |
| 604 | } |
| 605 | let remaining = (avail - fixed).max(0); |
| 606 | let wsum: f64 = weights.iter().filter(|&&w| w > 0.0).sum(); |
| 607 | let mut acc = 0i64; |
| 608 | let mut last_fr = 0usize; |
| 609 | if wsum > 0.0 { |
| 610 | for c in 0..ncols { |
| 611 | let w = weights.get(c).copied().unwrap_or(0.0); |
| 612 | if w > 0.0 { |
| 613 | let cw = (remaining as f64 * w / wsum) as i64; |
| 614 | colwidth[c] = Sp(cw as i32); |
| 615 | acc += cw; |
| 616 | last_fr = c; |
| 617 | } |
| 618 | } |
| 619 | // The rounding remainder lands on the last fractional column, so the widths sum to the budget. |
| 620 | colwidth[last_fr] = Sp(colwidth[last_fr].raw() + (remaining - acc) as i32); |
| 621 | } |
| 622 | return Ok(colwidth); |
| 623 | } |
| 624 | |
| 625 | let total_natural: i64 = natural.iter().sum(); |
| 626 | let mut colwidth = vec![Sp::ZERO; ncols]; |
| 627 | |
| 628 | if total_natural == 0 || total_natural <= avail { |
| 629 | for c in 0..ncols { |
| 630 | colwidth[c] = Sp(natural[c] as i32); |
| 631 | } |
| 632 | return Ok(colwidth); |
| 633 | } |
| 634 | |
| 635 | let sum_min: i64 = minw.iter().sum(); |
| 636 | let mut acc = 0i64; |
| 637 | if sum_min < avail { |
| 638 | let extra = avail - sum_min; |
| 639 | let span = total_natural - sum_min; |
| 640 | for c in 0..ncols { |
| 641 | let give = if span > 0 { |
| 642 | ((natural[c] - minw[c]) as i128 * extra as i128 / span as i128) as i64 |
| 643 | } else { |
| 644 | 0 |
| 645 | }; |
| 646 | let w = minw[c] + give; |
| 647 | colwidth[c] = Sp(w as i32); |
| 648 | acc += w; |
| 649 | } |
| 650 | } else { |
| 651 | // Even the minimums overrun the measure: proportional to natural width, words may protrude. |
| 652 | for c in 0..ncols { |
| 653 | let w = natural[c] * avail / total_natural; |
| 654 | colwidth[c] = Sp(w as i32); |
| 655 | acc += w; |
| 656 | } |
| 657 | } |
| 658 | // The rounding remainder lands on the last column, so the widths sum to the budget exactly. |
| 659 | let last = ncols - 1; |
| 660 | colwidth[last] = Sp(colwidth[last].raw() + (avail - acc) as i32); |
| 661 | Ok(colwidth) |
| 662 | } |
| 663 | |
| 664 | /// The widest line a cell's pieces set to when broken at `measure`: at a large measure this is the cell's |
| 665 | /// natural one-line width, at a tiny one the least it can shrink to. Zero for an empty cell. |
| 666 | fn measure_cell( |
| 667 | fonts: Arc<FontSet>, |
| 668 | base: Role, |
| 669 | size: Sp, |
| 670 | pieces: &[Piece], |
| 671 | measure: Sp, |
| 672 | ) |
| 673 | -> Outcome<Sp> |
| 674 | { |
| 675 | let mut m = Sp::ZERO; |
| 676 | // Width only: the block-edge model touches height and depth, never width or the break points, so the |
| 677 | // measurement is taken with the plain ascender/descender edges (`None`) whatever the table's own model. |
| 678 | for line in res!(break_cell(fonts, base, size, pieces, measure, size, None)) { |
| 679 | if line.width > m { |
| 680 | m = line.width; |
| 681 | } |
| 682 | } |
| 683 | Ok(m) |
| 684 | } |
| 685 | |
| 686 | /// Breaks a cell's pieces to its column, reusing the rich-paragraph line breaker at the column width, so a |
| 687 | /// cell's faces, superscripts and maths flow exactly as a paragraph's do. Each returned HBox is one line; |
| 688 | /// its leaves and glue are kept, with the natural width summed for alignment. The interline glue the |
| 689 | /// breaker inserts is dropped -- a band supplies its own vertical spacing. An empty cell yields no lines. |
| 690 | fn break_cell( |
| 691 | fonts: Arc<FontSet>, |
| 692 | base: Role, |
| 693 | size: Sp, |
| 694 | pieces: &[Piece], |
| 695 | colwidth: Sp, |
| 696 | leading: Sp, |
| 697 | // The block-edge model for the cell's first and last lines. `Some(cap)` seats the cell's top edge at the |
| 698 | // cap height and its bottom edge at the baseline -- Typst's default `top-edge: "cap-height", bottom-edge: |
| 699 | // "baseline"`, which its `measure` of a cell's content uses, so a one-line row is the cap-to-baseline |
| 700 | // extent, not the taller ascender-to-descender box. `None` keeps the ascender/descender edges (a table |
| 701 | // whose row heights are set from its own inset, or a width-only measurement where the edges do not matter). |
| 702 | cap: Option<Sp>, |
| 703 | ) |
| 704 | -> Outcome<Vec<CellLine>> |
| 705 | { |
| 706 | if pieces.is_empty() { |
| 707 | return Ok(Vec::new()); |
| 708 | } |
| 709 | // A cell is set ragged (`justify = false`): every space keeps its natural width, so the band's own |
| 710 | // justification to the table width -- for which the cells would otherwise hold the only stretchable |
| 711 | // glue -- cannot stretch or collapse the words within a cell. Typst sets table cells left-aligned. The |
| 712 | // block-edge model (`cap`) is threaded through so a breakable table (the glossary) measures its rows to |
| 713 | // the cap-height/baseline extent Typst does; the breaker already raises the first line's glyphs to match |
| 714 | // the lifted top edge and drops the last line's depth to the baseline, so the returned lines carry it. |
| 715 | let nodes = res!(break_paragraph_pieces(fonts.clone(), base, Dir::Ltr, size, pieces, colwidth, leading, false, true, Rgba::BLACK, cap)); |
| 716 | let mut out = Vec::new(); |
| 717 | for n in nodes { |
| 718 | if let Node::HBox(b) = n { |
| 719 | let mut w = Sp::ZERO; |
| 720 | for ch in &b.list { |
| 721 | match ch { |
| 722 | Node::Leaf(l) => w = w + l.dims.width, |
| 723 | Node::Glue(g) => w = w + g.natural, |
| 724 | _ => (), |
| 725 | } |
| 726 | } |
| 727 | out.push(CellLine { children: b.list, width: w, height: b.dims.height, depth: b.dims.depth }); |
| 728 | } |
| 729 | } |
| 730 | // The ragged line breaker rejects a short interior line whose slack exceeds its stretch tolerance, so |
| 731 | // a narrow column of few-word cells can leave a cell it cannot break within tolerance set overfull -- |
| 732 | // one line that overruns the column rule into the next cell. When that happens, re-wrap the cell |
| 733 | // greedily instead: pack words up to the column width, breaking before any word that would overrun. |
| 734 | // The greedy pass handles only plain-text pieces (a superscript mark included); a cell carrying an |
| 735 | // in-cell maths cluster keeps the breaker's result, since a maths box must be woven into the line as |
| 736 | // leaves rather than nested as one box (which the driver would draw as a placeholder rectangle). |
| 737 | let overfull = out.iter().any(|l| l.width > colwidth); |
| 738 | let has_math = pieces.iter().any(|p| matches!(p, Piece::Math { .. })); |
| 739 | if overfull && !has_math { |
| 740 | let mut lines = res!(greedy_break_cell(fonts, base, size, pieces, colwidth)); |
| 741 | // The greedy fallback builds its lines directly rather than through the breaker, so it applies the |
| 742 | // same block-edge model itself: the first line's glyphs are raised to the cap-height top and its box |
| 743 | // height dropped to the cap, and the last line's depth is dropped to the baseline. Greedy carries no |
| 744 | // maths (see above), so a text cell's baseline bottom is a plain zero depth. |
| 745 | if let Some(cap) = cap { |
| 746 | apply_cell_block_edges(&mut lines, cap); |
| 747 | } |
| 748 | return Ok(lines); |
| 749 | } |
| 750 | Ok(out) |
| 751 | } |
| 752 | |
| 753 | /// Applies the cap-height/baseline block-edge model to a greedily-broken cell: the first line's box top is |
| 754 | /// lifted from the face ascender to `cap` and its glyphs raised by the same amount so they still seat on |
| 755 | /// their baseline, and the last line's depth is dropped from the descender to the baseline. It mirrors what |
| 756 | /// [`set_lines`](crate::linebreak) does for the breaker's own output, for the fallback path that does not |
| 757 | /// pass through it. A single-line cell is both first and last and takes both edges. |
| 758 | fn apply_cell_block_edges(lines: &mut [CellLine], cap: Sp) { |
| 759 | if let Some(first) = lines.first_mut() { |
| 760 | if first.height > cap { |
| 761 | let drop = first.height - cap; |
| 762 | for child in &mut first.children { |
| 763 | if let Node::Leaf(l) = child { |
| 764 | l.shift = l.shift - drop; // negative lowers nothing, raises the glyph to the lifted top |
| 765 | } |
| 766 | } |
| 767 | first.height = cap; |
| 768 | } |
| 769 | } |
| 770 | if let Some(last) = lines.last_mut() { |
| 771 | last.depth = Sp::ZERO; // baseline bottom edge; the greedy path carries no maths hanging below it |
| 772 | } |
| 773 | } |
| 774 | |
| 775 | /// One shaped word (or a superscript mark) with the breakable space that precedes it, ready to be packed |
| 776 | /// into greedy ragged lines. |
| 777 | struct Unit { |
| 778 | node: Node, |
| 779 | width: Sp, |
| 780 | height: Sp, |
| 781 | depth: Sp, |
| 782 | space_before: Sp, // the natural width of the breakable space before this unit, zero when it is glued to the previous |
| 783 | } |
| 784 | |
| 785 | /// Wraps a plain-text cell greedily: each word is placed on the current line while it fits the column, |
| 786 | /// and a word that would overrun starts a fresh line. This is the ragged-right fallback for a cell the |
| 787 | /// optimal breaker leaves overfull -- a narrow column whose slack outruns the breaker's stretch tolerance |
| 788 | /// -- and it never sets a line wider than the column unless a single word is itself wider than the column. |
| 789 | fn greedy_break_cell( |
| 790 | fonts: Arc<FontSet>, |
| 791 | base: Role, |
| 792 | size: Sp, |
| 793 | pieces: &[Piece], |
| 794 | colwidth: Sp, |
| 795 | ) |
| 796 | -> Outcome<Vec<CellLine>> |
| 797 | { |
| 798 | let space = res!(ShapedText::new(fonts.clone(), base, Dir::Ltr, size, " ")); |
| 799 | let sp_w = space.dims().width; |
| 800 | |
| 801 | // Flatten the pieces into shaped word units, carrying the breakable space that precedes each. A run of |
| 802 | // spaces between two words is one breakable space; a mark clings to the word before it with no space. |
| 803 | let mut units: Vec<Unit> = Vec::new(); |
| 804 | let mut pending: i32 = 0; // spaces seen but not yet attached to the next word |
| 805 | for piece in pieces { |
| 806 | match piece { |
| 807 | Piece::Text { text, role } | Piece::SmallCaps { text, role } => { |
| 808 | // A small-caps run is broken into words exactly as a text run, each shaped with `smcp`. |
| 809 | let features: &[Feature] = match piece { |
| 810 | Piece::SmallCaps { .. } => &[Feature::SMALL_CAPS], |
| 811 | _ => &[], |
| 812 | }; |
| 813 | let mut word = String::new(); |
| 814 | for ch in text.chars() { |
| 815 | if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' { |
| 816 | if !word.is_empty() { |
| 817 | res!(push_word_unit(&mut units, fonts.clone(), *role, size, &word, Sp(sp_w.raw() * pending), features)); |
| 818 | word.clear(); |
| 819 | pending = 0; |
| 820 | } |
| 821 | if ch == ' ' { |
| 822 | pending += 1; |
| 823 | } |
| 824 | } else { |
| 825 | word.push(ch); |
| 826 | } |
| 827 | } |
| 828 | if !word.is_empty() { |
| 829 | res!(push_word_unit(&mut units, fonts.clone(), *role, size, &word, Sp(sp_w.raw() * pending), features)); |
| 830 | pending = 0; |
| 831 | } |
| 832 | }, |
| 833 | Piece::Mark(leaf) => { |
| 834 | units.push(Unit { |
| 835 | node: Node::Leaf(leaf.clone()), |
| 836 | width: leaf.dims.width, |
| 837 | height: leaf.dims.height, |
| 838 | depth: leaf.dims.depth, |
| 839 | space_before: Sp::ZERO, // a mark clings to the word before it |
| 840 | }); |
| 841 | pending = 0; |
| 842 | }, |
| 843 | Piece::Anchor(id) => { |
| 844 | // A margin note authored inside a cell: a zero-width marker carried so the driver records |
| 845 | // where it landed and the overlay draws its code, occupying none of the cell's width. |
| 846 | units.push(Unit { |
| 847 | node: Node::Anchor(id.clone()), |
| 848 | width: Sp::ZERO, |
| 849 | height: Sp::ZERO, |
| 850 | depth: Sp::ZERO, |
| 851 | space_before: Sp::ZERO, |
| 852 | }); |
| 853 | pending = 0; |
| 854 | }, |
| 855 | // A maths piece never reaches here: `break_cell` keeps the breaker's result for a cell with maths. |
| 856 | Piece::Math { .. } => {}, |
| 857 | } |
| 858 | } |
| 859 | |
| 860 | let mut out: Vec<CellLine> = Vec::new(); |
| 861 | let mut children: Vec<Node> = Vec::new(); |
| 862 | let mut cur_w = Sp::ZERO; |
| 863 | let mut cur_h = Sp::ZERO; |
| 864 | let mut cur_d = Sp::ZERO; |
| 865 | for unit in units { |
| 866 | let non_empty = !children.is_empty(); |
| 867 | let sb = if non_empty { unit.space_before } else { Sp::ZERO }; |
| 868 | // Break before this word when the line already holds something and the word (with its space) would |
| 869 | // overrun the column. A word wider than the column on its own line is left to overrun, as there is |
| 870 | // nothing narrower to set it to. |
| 871 | if non_empty && cur_w + sb + unit.width > colwidth { |
| 872 | out.push(CellLine { children: std::mem::take(&mut children), width: cur_w, height: cur_h, depth: cur_d }); |
| 873 | cur_w = Sp::ZERO; |
| 874 | cur_h = Sp::ZERO; |
| 875 | cur_d = Sp::ZERO; |
| 876 | } |
| 877 | if !children.is_empty() && sb.raw() > 0 { |
| 878 | children.push(Node::Glue(Glue::fixed(sb))); |
| 879 | cur_w = cur_w + sb; |
| 880 | } |
| 881 | children.push(unit.node); |
| 882 | cur_w = cur_w + unit.width; |
| 883 | if unit.height > cur_h { cur_h = unit.height; } |
| 884 | if unit.depth > cur_d { cur_d = unit.depth; } |
| 885 | } |
| 886 | if !children.is_empty() { |
| 887 | out.push(CellLine { children, width: cur_w, height: cur_h, depth: cur_d }); |
| 888 | } |
| 889 | Ok(out) |
| 890 | } |
| 891 | |
| 892 | /// Shapes one word in its face and appends it as a [`Unit`] with the breakable space that precedes it. |
| 893 | fn push_word_unit( |
| 894 | units: &mut Vec<Unit>, |
| 895 | fonts: Arc<FontSet>, |
| 896 | role: Role, |
| 897 | size: Sp, |
| 898 | word: &str, |
| 899 | space_before: Sp, |
| 900 | features: &[Feature], |
| 901 | ) |
| 902 | -> Outcome<()> |
| 903 | { |
| 904 | let shaped = res!(ShapedText::new_with_features(fonts, role, Dir::Ltr, size, word, features)); |
| 905 | let d = shaped.dims(); |
| 906 | units.push(Unit { |
| 907 | node: Node::Leaf(Leaf::text(shaped)), |
| 908 | width: d.width, |
| 909 | height: d.height, |
| 910 | depth: d.depth, |
| 911 | space_before, |
| 912 | }); |
| 913 | Ok(()) |
| 914 | } |
| 915 | |
| 916 | /// Builds one band: an HBox carrying the vertical rules at every column boundary, each the band's own |
| 917 | /// height, and one line from each cell placed at its column and alignment. The x cursor is tracked as |
| 918 | /// the driver will track it, so every rule lands on its fixed boundary whatever the lines do -- an |
| 919 | /// over-long line runs under the next rule rather than displacing it, keeping the columns straight |
| 920 | /// from row to row. |
| 921 | #[allow(clippy::too_many_arguments)] |
| 922 | fn build_band( |
| 923 | ncols: usize, |
| 924 | vrule_left: &[Sp], |
| 925 | tv: &[Sp], |
| 926 | content_left: &[Sp], |
| 927 | colwidth: &[Sp], |
| 928 | band_height: Sp, |
| 929 | table_width: Sp, |
| 930 | lines: &[Option<&CellLine>], |
| 931 | aligns: &[Align], |
| 932 | fill: Option<Rgba>, |
| 933 | ) |
| 934 | -> Node |
| 935 | { |
| 936 | let mut kids: Vec<Node> = Vec::new(); |
| 937 | let mut cursor = Sp::ZERO; |
| 938 | |
| 939 | // The row wash sits behind the band: a zero-width graphic leaf drawn first, so it does not advance the |
| 940 | // horizontal cursor the rules and lines are positioned against, yet its path spans the whole band and |
| 941 | // paints under them (the writer draws in frame order, so the rules and glyphs that follow sit over it). |
| 942 | if let Some(colour) = fill { |
| 943 | if let Some(g) = fill_band(table_width, band_height, colour) { |
| 944 | kids.push(Node::Leaf(g)); |
| 945 | } |
| 946 | } |
| 947 | |
| 948 | for b in 0..=ncols { |
| 949 | // The vertical rule at this boundary, seated on its fixed x. |
| 950 | kids.push(Node::Glue(Glue::fixed(vrule_left[b] - cursor))); |
| 951 | kids.push(Node::Leaf(Leaf::rule(Dims::new(tv[b], band_height, Sp::ZERO)))); |
| 952 | cursor = vrule_left[b] + tv[b]; |
| 953 | |
| 954 | if b < ncols { |
| 955 | if let Some(line) = lines[b] { |
| 956 | let slack = (colwidth[b].raw() - line.width.raw()).max(0); |
| 957 | let off = match aligns[b] { |
| 958 | Align::Left => 0, |
| 959 | Align::Centre => slack / 2, |
| 960 | Align::Right => slack, |
| 961 | }; |
| 962 | let target = content_left[b] + Sp(off); |
| 963 | kids.push(Node::Glue(Glue::fixed(target - cursor))); |
| 964 | for ch in &line.children { |
| 965 | kids.push(ch.clone()); |
| 966 | } |
| 967 | cursor = target + line.width; |
| 968 | } |
| 969 | // An empty cell adds nothing; the next boundary's glue jumps the column's width. |
| 970 | } |
| 971 | } |
| 972 | Node::HBox(BoxNode::new(kids, Dims::new(table_width, band_height, Sp::ZERO))) |
| 973 | } |
| 974 | |
| 975 | /// Builds the background wash of one band: a graphic leaf whose single fill covers the band rectangle in |
| 976 | /// `colour`. The leaf declares zero width so it does not move the band's horizontal cursor -- the path is |
| 977 | /// drawn from the leaf's placement whatever the declared advance -- and its height matches the band, so |
| 978 | /// the wash tiles seamlessly with the band above and below. `None` for a degenerate (zero-area) band, so |
| 979 | /// `Path::rect` is never handed an empty rectangle. |
| 980 | fn fill_band(width: Sp, height: Sp, colour: Rgba) -> Option<Leaf> { |
| 981 | let w = width.to_pt() as f32; |
| 982 | let h = height.to_pt() as f32; |
| 983 | if w <= 0.0 || h <= 0.0 { |
| 984 | return None; |
| 985 | } |
| 986 | let rect = Path::rect(Bounds::new(0.0, 0.0, w, h)).ok()?; |
| 987 | let graphic = Graphic::new(vec![DrawOp::Fill { path: rect, colour }], Dims::new(Sp::ZERO, height, Sp::ZERO)); |
| 988 | Some(Leaf::graphic(graphic)) |
| 989 | } |
| 990 | |
| 991 | /// Pushes a full-width horizontal rule and advances the running height by its thickness. |
| 992 | fn push_hrule(children: &mut Vec<Node>, total_h: &mut Sp, width: Sp, thick: Sp) { |
| 993 | children.push(Node::Leaf(Leaf::rule(Dims::new(width, thick, Sp::ZERO)))); |
| 994 | *total_h += thick; |
| 995 | } |
| 996 | |
| 997 | #[cfg(test)] |
| 998 | mod tests { |
| 999 | use super::*; |
| 1000 | use crate::theme::Theme; |
| 1001 | |
| 1002 | /// The cell cap-edge (Unit D3-B) shortens a one-line 9pt glossary cell from the face |
| 1003 | /// ascender/descender extent to the cap-height/baseline extent Typst measures against. This both |
| 1004 | /// guards the fix and records the measured drop the brief asks be proved (~22.3pt -> ~17.9pt row, the |
| 1005 | /// ~4.4pt-per-row overshoot): the height compared here is the cell's own line extent, before the row's |
| 1006 | /// padding and rules are added around it. |
| 1007 | #[test] |
| 1008 | fn cell_cap_edge_shortens_a_one_line_row() -> Outcome<()> { |
| 1009 | let fonts = std::sync::Arc::new(res!(crate::fonts::libertinus())); |
| 1010 | let size = Sp::from_pt(9.0); // GLOSSARY_TEXT_PT |
| 1011 | let style = Theme::default(); |
| 1012 | let cap = crate::doc::cap_edge(&style, size); |
| 1013 | let colwidth = Sp::from_pt(200.0); // wide enough that the term sets on one line |
| 1014 | let pieces = vec![Piece::Text { text: "Alpha".to_string(), role: Role::Body }]; |
| 1015 | |
| 1016 | let plain = res!(break_cell(fonts.clone(), Role::Body, size, &pieces, colwidth, size, None)); |
| 1017 | let capped = res!(break_cell(fonts.clone(), Role::Body, size, &pieces, colwidth, size, Some(cap))); |
| 1018 | if plain.len() != 1 || capped.len() != 1 { |
| 1019 | return Err(err!("Expected one line each: plain {}, capped {}.", plain.len(), capped.len(); Test, Mismatch)); |
| 1020 | } |
| 1021 | let plain_v = (plain[0].height + plain[0].depth).to_pt(); |
| 1022 | let capped_v = (capped[0].height + capped[0].depth).to_pt(); |
| 1023 | eprintln!("[cap-edge] one-line 9pt cell extent: plain {:.2}pt -> capped {:.2}pt (drop {:.2}pt), cap {:.2}pt", |
| 1024 | plain_v, capped_v, plain_v - capped_v, cap.to_pt()); |
| 1025 | // The capped extent must be strictly shorter (the whole point of the fix), and the last line's depth |
| 1026 | // must drop to the baseline (zero) for a text cell. |
| 1027 | if capped_v >= plain_v { |
| 1028 | return Err(err!("The cap-edge did not shorten the cell: plain {:.2}pt, capped {:.2}pt.", plain_v, capped_v; Test, Mismatch)); |
| 1029 | } |
| 1030 | if capped[0].depth != Sp::ZERO { |
| 1031 | return Err(err!("The capped cell's last-line depth was not the baseline: {} sp.", capped[0].depth.raw(); Test, Mismatch)); |
| 1032 | } |
| 1033 | Ok(()) |
| 1034 | } |
| 1035 | |
| 1036 | // A one-entry bibliography, enough to resolve one in-text citation to its Chicago author-year form. |
| 1037 | const CELL_BIB: &str = r#" |
| 1038 | @book{scott1976moral, |
| 1039 | author = {Scott, James C.}, |
| 1040 | title = {The Moral Economy of the Peasant}, |
| 1041 | year = {1976}, |
| 1042 | publisher = {Yale University Press} |
| 1043 | } |
| 1044 | "#; |
| 1045 | |
| 1046 | /// A table cell now runs through the body's own segment pipeline, so a `#cite` in a cell renders to its |
| 1047 | /// author-year text against the bibliography, an `#index[...]` marker is gathered into the back-matter |
| 1048 | /// index, and a `#claim-refs`/`#claim-label` code reaches the reverse claim index -- the three inline |
| 1049 | /// kinds a cell used to drop silently. Reverting `build_grid` to the old `cell_pieces` reds this test: the |
| 1050 | /// citation would render as the bare key `(scott1976moral)`, and both gathers would stay empty. |
| 1051 | #[test] |
| 1052 | fn table_cell_cite_renders_and_index_and_claim_are_gathered() -> Outcome<()> { |
| 1053 | let fonts = Arc::new(res!(crate::fonts::libertinus())); |
| 1054 | let style = Theme::default(); |
| 1055 | let geom = PageGeometry::a4(); |
| 1056 | let bib = res!(Bibliography::parse(CELL_BIB)); |
| 1057 | |
| 1058 | let cell = Cell::rich(vec![ |
| 1059 | Segment::text("See "), |
| 1060 | Segment::cite(vec!["scott1976moral".to_string()]), |
| 1061 | Segment::index("Peasant economy", None, false, vec![Segment::text("Peasant economy")]), |
| 1062 | Segment::margin_note("", vec!["C7".to_string()]), |
| 1063 | ], Align::Left); |
| 1064 | let table = Table::new(false, vec![Row::new(vec![cell])]); |
| 1065 | |
| 1066 | let mut foot_no = 0u32; |
| 1067 | let mut ref_no = 0u32; |
| 1068 | let mut margin_no = 0u32; |
| 1069 | let mut seen: HashSet<String> = HashSet::new(); |
| 1070 | let mut idx = IndexGather::default(); |
| 1071 | let mut claim = ClaimGather::default(); |
| 1072 | let refs: HashMap<String, String> = HashMap::new(); |
| 1073 | |
| 1074 | let (grid, _bases) = res!(build_grid( |
| 1075 | fonts, geom, &style, &table, 1, |
| 1076 | &mut foot_no, &mut ref_no, &mut margin_no, &mut seen, &mut idx, &mut claim, Some(&bib), &refs)); |
| 1077 | |
| 1078 | // The citation renders to its Chicago author-year form, not the bracketed raw key. |
| 1079 | let texts: Vec<&str> = grid[0][0].iter().filter_map(|p| match p { |
| 1080 | Piece::Text { text, .. } => Some(text.as_str()), |
| 1081 | _ => None, |
| 1082 | }).collect(); |
| 1083 | if !texts.iter().any(|t| *t == "(Scott 1976)") { |
| 1084 | return Err(err!("The table cell's #cite did not render to its author-year text; \ |
| 1085 | text pieces were {:?}.", texts; Test, Mismatch)); |
| 1086 | } |
| 1087 | // The index marker was gathered, so the back-matter index will list the term with the page it lands on. |
| 1088 | if !idx.occ.iter().any(|(term, _, _, _, _)| term == "Peasant economy") { |
| 1089 | return Err(err!("The table cell's #index marker was not gathered into the back-matter index."; Test, Mismatch)); |
| 1090 | } |
| 1091 | // The claim code reached the reverse claim index. |
| 1092 | if !claim.occ.iter().any(|(code, _)| code == "C7") { |
| 1093 | return Err(err!("The table cell's claim reference did not reach the reverse claim index."; Test, Mismatch)); |
| 1094 | } |
| 1095 | Ok(()) |
| 1096 | } |
| 1097 | } |