Oregami
Repositories/oxedyne/fe2o3

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
19use crate::bib::Bibliography;
20use crate::doc::{
21 ClaimGather,
22 IndexGather,
23 Segment,
24 build_pieces,
25};
26use crate::page::PageGeometry;
27use crate::theme::Theme;
28use crate::font::ShapedText;
29use crate::ir::{
30 BoxNode,
31 Dims,
32 DrawOp,
33 Glue,
34 Graphic,
35 Leaf,
36 Node,
37 Sp,
38};
39use crate::linebreak::{
40 Piece,
41 break_paragraph_pieces,
42};
43
44use oxedyne_fe2o3_core::prelude::*;
45use oxedyne_fe2o3_font::{
46 face::Role,
47 set::FontSet,
48 shape::{
49 Dir,
50 Feature,
51 },
52};
53use oxedyne_fe2o3_graphics::{
54 colour::Rgba,
55 path::{
56 Bounds,
57 Path,
58 },
59};
60
61use std::collections::{
62 HashMap,
63 HashSet,
64};
65use std::sync::Arc;
66
67/// How a cell's text sits within its column width.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub 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)]
81pub 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)]
92pub 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
98impl 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)]
122pub struct Row {
123 pub cells: Vec<Cell>,
124}
125
126impl 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)]
135pub 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
144impl 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.
166struct 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)]
180fn 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)]
365pub 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)]
411pub 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.
464fn 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.
497fn 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)]
510fn 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.
549pub(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.
560fn 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.
666fn 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.
690fn 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.
758fn 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.
777struct 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.
789fn 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.
893fn 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)]
922fn 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.
980fn 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.
992fn 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)]
998mod 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}