//! Tables: the table sprms (sgc 5) carried by a row-end (TTP) mark's grpprl, and the //! assembly of cell-ended paragraphs into [`Table`] blocks ([MS-DOC] §2.6.3, §2.4.3). use std::sync::Arc; use wordcraft_doc::Rgb; use wordcraft_doc::para::Paragraph; use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; use wordcraft_doc::table::{Cell, Row, Table}; use wordcraft_doc::{Block, Blocks, para_block}; use crate::fmt; use crate::sprm::{self, Prl}; // Paragraph sprms for table membership. const P_IN_TABLE: u16 = 0x2416; const P_TTP: u16 = 0x2417; /// `sprmPItap`: table depth (we only model depth 1; deeper rows fall back to paragraphs). const P_ITAP: u16 = 0x6649; // Table sprms ([MS-DOC] §2.6.3). const T_JC: u16 = 0x5400; const T_JC_90: u16 = 0x548A; const T_DXA_LEFT: u16 = 0x9601; const T_DXA_GAP_HALF: u16 = 0x9602; const T_TABLE_HEADER: u16 = 0x3404; const T_TABLE_BORDERS_80: u16 = 0xD605; const T_ROW_HEIGHT: u16 = 0x9407; const T_DEF_TABLE: u16 = 0xD608; const T_DEF_TABLE_SHD_80: u16 = 0xD609; /// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62. const T_DEF_TABLE_SHD: u16 = 0xD612; const T_DEF_TABLE_SHD_2ND: u16 = 0xD616; const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C; /// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell /// ranges, which Word 2007+ writes next to the above. const T_DEF_TABLE_SHD_RAW: u16 = 0xD670; const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671; const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672; const T_CANT_SPLIT_90: u16 = 0x3403; const T_MERGE: u16 = 0x5624; const T_VERT_MERGE: u16 = 0xD62B; const T_VERT_ALIGN: u16 = 0xD62C; const T_SET_SHD: u16 = 0xD62D; const T_CANT_SPLIT: u16 = 0x3466; const T_CELL_WIDTH: u16 = 0xD635; /// Most grid columns we build from a `sprmTDefTable` (the model's own cap is 63; one more /// edge is fine but merged rows can sum spans slightly over, so clamp when assembling). const MAX_COLS: usize = 63; /// What a paragraph mark says about its table context, from its PAPX. #[derive(Default)] pub(crate) struct RowInfo { pub(crate) in_table: bool, pub(crate) ttp: bool, /// The mark itself was 0x07 (a cell end), set by the walker. pub(crate) cell_mark: bool, /// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs). pub(crate) depth: u32, /// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row, /// so each row keeps its own; ascending in well-formed rows, the first edge usually 0 /// (the row origin). Rows without a `sprmTDefTable` keep this empty. pub(crate) edges: Vec, height: Option, height_rule: HeightRule, header: bool, cant_split: bool, align: Option, indent: Option, borders: Option, /// Horizontal merges: (itcFirst, itcLim) cell-index ranges. merges: Vec<(u8, u8)>, /// Per-cell overrides keyed by (pre-merge) cell index. vmerge: Vec<(u8, VMerge)>, /// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an /// automatic `Shd` (`None`) clears it. shading: Vec<(u8, Option)>, widths: Vec<(u8, f32)>, width_pcts: Vec<(u8, f32)>, valign: Vec<(u8, VAlign)>, } /// Read the table context out of a paragraph mark's PAPX grpprl. impl RowInfo { /// The row height from `sprmTDyaRowHeight`, for tests. #[cfg(test)] pub(crate) fn height(&self) -> Option { self.height } /// The fill cell `i` ends up with (the last shading entry for it wins), for tests. #[cfg(test)] pub(crate) fn shading_of(&self, i: u8) -> Option { self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s) } /// The width cell `i` ends up with, for tests. #[cfg(test)] pub(crate) fn width_of(&self, i: u8) -> Option { self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w) } /// The width percentage cell `i` ends up with, for tests. #[cfg(test)] pub(crate) fn width_pct_of(&self, i: u8) -> Option { self.width_pcts.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w) } } pub(crate) fn decode(papx: &[u8]) -> RowInfo { let mut r = RowInfo::default(); let mut gap_half: Option = None; let mut dxa_left: Option = None; for prl in sprm::iter(papx) { // The membership sprms are paragraph sprms (sgc 1); the rest here are table sprms // (sgc 5), so match opcodes directly. match prl.op { P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0, P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0, P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32, T_DEF_TABLE => r.edges = def_table_edges(prl.operand), T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0, T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0, T_ROW_HEIGHT => { if let Some(v) = s16(&prl) { if v < 0 { r.height_rule = HeightRule::Exact; r.height = Some((-(v as f32) / 20.0).clamp(0.0, 1584.0)); } else { r.height_rule = HeightRule::AtLeast; r.height = Some((v as f32 / 20.0).clamp(0.0, 1584.0)); } } } T_JC | T_JC_90 => { r.align = match u16_of(&prl).unwrap_or(0) { 1 => Some(Align::Center), 2 => Some(Align::Right), _ => Some(Align::Left), }; } T_DXA_LEFT => dxa_left = s16(&prl), T_DXA_GAP_HALF => gap_half = s16(&prl), T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand), T_MERGE => { if let [a, b, ..] = prl.operand && b > a { r.merges.push((*a, *b)); } } T_VERT_MERGE => { // cb, itc, flag: 1 = continues the merge from above, 3 = restarts it. if let [_, itc, flag, ..] = prl.operand { let m = match flag { 3 => VMerge::Restart, 1 => VMerge::Continue, _ => VMerge::None, }; if m != VMerge::None { r.vmerge.push((*itc, m)); } } } T_SET_SHD => { if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb) && let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[]) { for c in *a..(*b).min(MAX_COLS as u8) { r.shading.push((c, Some(rgb))); } } } T_DEF_TABLE_SHD_80 => { let cells = prl.operand.get(1..).unwrap_or(&[]); for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() { if let Some(rgb) = shd80_rgb(b) { r.shading.push((i as u8, Some(rgb))); } } } T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => { // Each array covers its own cell range (0-21, 22-43, 44-62); extra entries // are ignored so an over-long array can't override the next range. let (first, lim) = match prl.op { T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44), T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS), _ => (0, 22), }; let cells = prl.operand.get(1..).unwrap_or(&[]); let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD); for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() { // In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style // shades it", so it leaves any earlier fill alone. if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) { continue; } r.shading.push(((first + i) as u8, shd_rgb(b))); } } T_CELL_WIDTH => { // Operand: cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes). // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635). // ftsWidth: 3 = twips (dxa), 2 = percentage (50ths of a percent). if let [_, a, b, fts, w0, w1, ..] = prl.operand { if *fts == 3 { let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0); for c in *a..(*b).min(MAX_COLS as u8) { r.widths.push((c, w)); } } else if *fts == 2 { let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0); for c in *a..(*b).min(MAX_COLS as u8) { r.width_pcts.push((c, pct)); } } } } T_VERT_ALIGN => { if let [_, a, b, va, ..] = prl.operand { let v = match va { 1 => VAlign::Center, 2 => VAlign::Bottom, _ => VAlign::Top, }; for c in *a..*b { r.valign.push((c, v)); } } } _ => {} } } // The table indent is the row origin minus half the cell gap. if let Some(l) = dxa_left { let gap = gap_half.unwrap_or(0) as f32 / 20.0; r.indent = Some(((l as f32 / 20.0) - gap).clamp(-1584.0, 1584.0)); } r } /// A finished paragraph plus the bookkeeping the block assembly needs. pub(crate) struct ParaOut { pub(crate) para: Paragraph, /// CP just past the terminating mark. pub(crate) end_cp: u32, /// Table context from the mark's PAPX (empty default = not in a table). pub(crate) row: RowInfo, } /// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through. /// Rows that never close (or tables nested deeper than we model) degrade to paragraphs so /// no text is lost. pub(crate) fn assemble(paras: Vec) -> Blocks { let mut out: Blocks = Vec::new(); let mut rows: Vec = Vec::new(); let mut cells: Vec = Vec::new(); let mut cell: Blocks = Vec::new(); let mut grid: Vec = Vec::new(); let mut props = TableProps::default(); let mut open = false; let mut depth_seen = 0u32; fn flush_cell(cells: &mut Vec, cell: &mut Blocks) { let mut blocks = std::mem::take(cell); if blocks.is_empty() { blocks.push(para_block(Paragraph::new())); } cells.push(Cell { props: CellProps { span: 1, ..Default::default() }, blocks, controls: Default::default() }); } fn build_row(mut cells: Vec, info: &RowInfo) -> Row { // Per-cell overrides first (sprm cell indices count pre-merge cells), then merges. for (i, c) in cells.iter_mut().enumerate() { let i = i as u8; if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) { c.props.vmerge = *m; } if let Some((_, w)) = info.widths.iter().rfind(|(k, _)| *k == i) { c.props.width = Some(*w); } if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) { c.props.width_pct = Some(*pct); } if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) { c.props.shading = *s; } if let Some((_, v)) = info.valign.iter().rfind(|(k, _)| *k == i) { c.props.valign = Some(*v); } } for &(a, b) in info.merges.iter().take(MAX_COLS) { let a = a as usize; let b = (b as usize).min(cells.len()); if a < b && b <= cells.len() && b - a > 1 { let removed: Vec = cells.drain(a + 1..b).collect(); if let Some(first) = cells.get_mut(a) { first.props.span = ((b - a) as u32).min(63); for c in removed { first.blocks.extend(c.blocks.into_iter().filter(|x| x.as_para().is_none_or(|p| !p.is_empty()))); } if first.blocks.len() > 1 && first.blocks.first().and_then(|x| x.as_para()).is_some_and(Paragraph::is_empty) { first.blocks.remove(0); } } } } Row { props: RowProps { height: info.height, height_rule: info.height_rule, header: info.header, cant_split: info.cant_split, ..Default::default() }, cells, controls: Default::default(), } } fn flush_table(out: &mut Blocks, rows: &mut Vec, cells: &mut Vec, cell: &mut Blocks, grid: &[f32], props: &TableProps, nested: bool) { if !cells.is_empty() || !cell.is_empty() { flush_cell(cells, cell); // A stray trailing row (no TTP of its own): size its cells from the grid. let mut stray = build_row(std::mem::take(cells), &RowInfo::default()); for (i, c) in stray.cells.iter_mut().enumerate() { if c.props.width.is_none() && c.props.span == 1 { c.props.width = grid.get(i).copied(); } } rows.push(stray); } if rows.is_empty() { return; } if nested { // Unclosed or nested deeper than we model: keep the text, drop the table shape. for r in rows.iter() { for c in &r.cells { out.extend(c.blocks.iter().cloned()); } } } else { out.push(Arc::new(Block::Table(Table { props: props.clone(), grid: grid.to_vec(), rows: std::mem::take(rows), controls: Default::default(), }))); } rows.clear(); } for p in paras { let info = p.row; let is_table = info.in_table || info.ttp || info.cell_mark; if !is_table { if open { flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1); grid.clear(); // Table props are per-table (first row carrying the sprm wins within the // table): reset the accumulator so one table's borders, align, indent and // cell margins never leak into the next (the repro's first table wrote // Word's default 108-twip gap, and every later table — including the // change history with its own 40-twip gap — inherited 5.4 pt margins). props = TableProps::default(); open = false; depth_seen = 0; } out.push(para_block(p.para)); continue; } if !open { open = true; } depth_seen = depth_seen.max(info.depth); if grid.is_empty() { // Column widths from the table's first `sprmTDefTable`. grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect(); } if info.borders.is_some() && props.borders.is_none() { props.borders = info.borders; } if props.align.is_none() { props.align = info.align; } if props.indent.is_none() { props.indent = info.indent; } if info.cell_mark { // A row-end (TTP) mark terminates the row. Word writes rows as N content // cells each ended by their own cell mark, followed by an empty row-end mark // that carries the row's TDefTable (the 3GPP change history: 8 content marks // + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no // content of its own and the TDefTable already has a column per flushed cell, // it is a pure row terminator, not the end of a phantom (N+1)th cell — such a // cell has no column (width None) and shifts every real cell's span one grid // column over. A TTP mark with content, or one short of the TDefTable's // columns, still ends a real cell (rows without a TDefTable keep today's // shape: the count is unknowable, so the mark always flushes). let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len(); // ...but a row-end mark with no cells before it still makes a (one-cell) row. let row_end_only = row_end_only && !cells.is_empty(); if !p.para.text.is_empty() { cell.push(para_block(p.para)); } if !row_end_only { flush_cell(&mut cells, &mut cell); } if info.ttp { let row = build_row(std::mem::take(&mut cells), &info); rows.push(row); } } else { cell.push(para_block(p.para)); } } if open { flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1); } out } /// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then /// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored: /// per-row, in operand order; sorting and capping happen when the table grid is assembled. fn def_table_edges(operand: &[u8]) -> Vec { let itc = operand.get(2).copied().unwrap_or(0) as usize; if itc == 0 { return Vec::new(); } // itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end. let mut edges = Vec::with_capacity((itc + 1).min(256)); for i in 0..=itc { match operand.get(3 + i * 2..3 + i * 2 + 2) { Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0), None => break, } } edges } /// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top, /// left, bottom, right, inside-horizontal, inside-vertical. fn table_borders(operand: &[u8]) -> Option { let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80); Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) }) } /// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte. fn brc80(b: &[u8]) -> Option { let [w, ty, ico, flags] = b else { return None }; let style = match ty { 0x00 => BorderStyle::None, 0x02 | 0x15 => BorderStyle::Thick, 0x03 => BorderStyle::Double, 0x04 | 0x05 => BorderStyle::Dotted, 0x06 | 0x07 => BorderStyle::Dashed, 0x08 | 0x09 => BorderStyle::DotDash, 0x0A | 0x18 => BorderStyle::Wave, 0x11 => BorderStyle::Triple, _ => BorderStyle::Single, }; if *ty == 0 && *w == 0 { return None; } Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 }) } /// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns /// show the foreground; clear (0) and the other patterns show the background (Word writes a /// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill. fn shd_rgb(b: &[u8]) -> Option { let cv = |b: &[u8]| -> Option { match b { [_, _, _, 0xFF] => None, // fAuto [r, g, bl, _] => Some(Rgb(*r, *g, *bl)), _ => None, } }; let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?; match ipat { 0xFFFF => None, 1 => cv(b.get(0..4)?), _ => cv(b.get(4..8)?), } } /// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in /// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word /// 97 writes a plain fill; ico 0 is automatic (no fill). fn shd80_rgb(b: &[u8]) -> Option { let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]); let fore = v & 0x1F; let back = (v >> 5) & 0x1F; let ipat = v >> 10; if fore == 0x1F && back == 0x1F { return None; } fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 }) } fn u16_of(p: &Prl) -> Option { match p.operand { [b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])), _ => None, } } fn s16(p: &Prl) -> Option { u16_of(p).map(|v| v as i16) } fn i32_of(p: &Prl) -> Option { match p.operand { [b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])), _ => None, } }