1use mdsee_core::{Alignment, Inline, Link, Table, TableCell};
15use unicode_segmentation::UnicodeSegmentation;
16use unicode_width::UnicodeWidthStr;
17
18use crate::model::{LayoutBlock, LayoutLine, LayoutSpan, SemanticStyle, TableLayout};
19use crate::wrap::{is_cjk_breakable, wrap_inlines};
20
21#[derive(Debug, Clone, Copy, Default)]
23pub struct TableLayoutEngine;
24
25impl TableLayoutEngine {
26 pub fn layout(table: &Table, width: usize) -> TableLayout {
28 let ncols = table
29 .alignments
30 .len()
31 .max(table.header.len())
32 .max(table.rows.iter().map(Vec::len).max().unwrap_or(0));
33 if ncols == 0 {
34 return TableLayout { lines: Vec::new() };
35 }
36
37 let alignments = normalize_alignments(&table.alignments, ncols);
39 let header = normalize_row(&table.header, ncols);
40 let rows: Vec<Vec<&TableCell>> = table
41 .rows
42 .iter()
43 .map(|row| normalize_row(row, ncols))
44 .collect();
45
46 let mut min = vec![0usize; ncols];
48 let mut preferred = vec![0usize; ncols];
49 let all_rows = std::iter::once(&header).chain(rows.iter());
50 for row in all_rows {
51 for (col, cell) in row.iter().enumerate() {
52 min[col] = min[col].max(cell_min_width(&cell.inlines));
53 preferred[col] = preferred[col].max(cell_preferred_width(&cell.inlines));
54 }
55 }
56
57 let overhead = 3 * ncols + 1; let avail = width.saturating_sub(overhead).max(ncols);
60 let mut assigned: Vec<usize> = preferred.iter().map(|p| (*p).min(avail)).collect();
61 shrink_to_fit(&mut assigned, &min, avail);
62
63 let header_cells = layout_row_cells(&header, &assigned, SemanticStyle::Strong);
65 let header_lines =
66 compose_rows(std::slice::from_ref(&header_cells), &alignments, &assigned);
67 let body_cells: Vec<Vec<Vec<LayoutLine>>> = rows
68 .iter()
69 .map(|row| layout_row_cells(row, &assigned, SemanticStyle::Body))
70 .collect();
71
72 let mut lines = Vec::new();
74 lines.push(separator_line(&assigned, '┌', '┬', '┐'));
75 if !table.header.is_empty() {
76 lines.extend(header_lines);
77 lines.push(separator_line(&assigned, '├', '┼', '┤'));
78 }
79 lines.extend(compose_rows(&body_cells, &alignments, &assigned));
80 lines.push(separator_line(&assigned, '└', '┴', '┘'));
81
82 TableLayout { lines }
83 }
84}
85
86pub(crate) fn layout_table(table: &Table, width: usize) -> LayoutBlock {
88 LayoutBlock::Table(TableLayoutEngine::layout(table, width))
89}
90
91fn normalize_alignments(alignments: &[Alignment], ncols: usize) -> Vec<Alignment> {
92 let mut out: Vec<Alignment> = alignments.to_vec();
93 out.resize(ncols, Alignment::None);
94 out
95}
96
97fn normalize_row(row: &[TableCell], ncols: usize) -> Vec<&TableCell> {
98 let mut out: Vec<&TableCell> = row.iter().collect();
99 static EMPTY: TableCell = TableCell {
100 inlines: Vec::new(),
101 };
102 while out.len() < ncols {
103 out.push(&EMPTY);
104 }
105 out
106}
107
108fn shrink_to_fit(assigned: &mut [usize], min: &[usize], avail: usize) {
110 while assigned.iter().sum::<usize>() > avail {
111 let target = assigned
112 .iter()
113 .zip(min)
114 .enumerate()
115 .filter(|(_, (a, m))| **a > **m)
116 .max_by_key(|(_, (a, m))| **a - **m)
117 .map(|(index, _)| index);
118 match target {
119 Some(index) => assigned[index] -= 1,
120 None => break,
121 }
122 }
123 while assigned.iter().sum::<usize>() > avail {
125 let target = assigned
126 .iter()
127 .enumerate()
128 .filter(|(_, a)| **a > 1)
129 .max_by_key(|(_, a)| **a)
130 .map(|(index, _)| index);
131 match target {
132 Some(index) => assigned[index] -= 1,
133 None => break,
134 }
135 }
136}
137
138fn cell_min_width(inlines: &[Inline]) -> usize {
143 let text = plain_text(inlines);
144 let mut max_run = 1usize;
145 let mut run = 0usize;
146 let mut prev_breakable = false;
147 for cluster in text.graphemes(true) {
148 if cluster.trim().is_empty() {
149 max_run = max_run.max(run);
150 run = 0;
151 prev_breakable = false;
152 continue;
153 }
154 let starts_breakable = cluster.chars().any(is_cjk_breakable);
155 if run > 0 && (prev_breakable || starts_breakable) {
156 max_run = max_run.max(run);
157 run = 0;
158 }
159 run += UnicodeWidthStr::width(cluster);
160 prev_breakable = starts_breakable;
161 }
162 max_run.max(run)
163}
164
165fn cell_preferred_width(inlines: &[Inline]) -> usize {
167 let text = plain_text(inlines);
168 let words: Vec<&str> = text.split_whitespace().collect();
169 if words.is_empty() {
170 return 0;
171 }
172 words
173 .iter()
174 .map(|w| UnicodeWidthStr::width(*w))
175 .sum::<usize>()
176 + words.len()
177 - 1
178}
179
180pub(crate) fn plain_text(inlines: &[Inline]) -> String {
182 let mut out = String::new();
183 fn walk(inlines: &[Inline], out: &mut String) {
184 for inline in inlines {
185 match inline {
186 Inline::Text(run) => out.push_str(&run.content),
187 Inline::Code(code) => out.push_str(code),
188 Inline::SoftBreak | Inline::HardBreak => out.push(' '),
189 Inline::Emphasis(children)
190 | Inline::Strong(children)
191 | Inline::Strike(children)
192 | Inline::Link(Link { children, .. }) => walk(children, out),
193 }
194 }
195 }
196 walk(inlines, &mut out);
197 out
198}
199
200fn layout_row_cells(
201 row: &[&TableCell],
202 assigned: &[usize],
203 base: SemanticStyle,
204) -> Vec<Vec<LayoutLine>> {
205 row.iter()
206 .enumerate()
207 .map(|(col, cell)| wrap_inlines(&cell.inlines, assigned[col].max(1), base))
208 .collect()
209}
210
211fn compose_rows(
213 rows: &[Vec<Vec<LayoutLine>>],
214 alignments: &[Alignment],
215 assigned: &[usize],
216) -> Vec<LayoutLine> {
217 let mut lines = Vec::new();
218 for cells in rows {
219 let height = cells.iter().map(Vec::len).max().unwrap_or(0);
220 for line_index in 0..height {
221 let mut spans = vec![border_span("│")];
222 for (col, cell) in cells.iter().enumerate() {
223 spans.push(space_span(1));
224 match cell.get(line_index) {
225 Some(line) => {
226 spans.extend(pad_cell_line(line.clone(), assigned[col], alignments[col]))
227 }
228 None => spans.push(space_span(assigned[col])),
229 }
230 spans.push(space_span(1));
231 spans.push(border_span("│"));
232 }
233 lines.push(LayoutLine { spans });
234 }
235 }
236 lines
237}
238
239fn pad_cell_line(mut line: LayoutLine, width: usize, alignment: Alignment) -> Vec<LayoutSpan> {
241 let current = line
242 .spans
243 .iter()
244 .map(|span| UnicodeWidthStr::width(span.content.as_str()))
245 .sum::<usize>();
246 let pad = width.saturating_sub(current);
247 let (left, right) = match alignment {
248 Alignment::Left | Alignment::None => (0, pad),
249 Alignment::Center => (pad / 2, pad - pad / 2),
250 Alignment::Right => (pad, 0),
251 };
252 let mut spans = Vec::with_capacity(line.spans.len() + 2);
253 spans.push(space_span(left));
254 spans.append(&mut line.spans);
255 spans.push(space_span(right));
256 spans
257}
258
259fn separator_line(assigned: &[usize], left: char, middle: char, right: char) -> LayoutLine {
260 let mut content = String::new();
261 content.push(left);
262 for (index, width) in assigned.iter().enumerate() {
263 content.push_str(&"─".repeat(width + 2));
264 if index + 1 < assigned.len() {
265 content.push(middle);
266 }
267 }
268 content.push(right);
269 LayoutLine {
270 spans: vec![LayoutSpan {
271 content,
272 style: SemanticStyle::Border,
273 link: None,
274 }],
275 }
276}
277
278fn border_span(content: &str) -> LayoutSpan {
279 LayoutSpan {
280 content: content.to_string(),
281 style: SemanticStyle::Border,
282 link: None,
283 }
284}
285
286fn space_span(width: usize) -> LayoutSpan {
287 LayoutSpan {
288 content: " ".repeat(width),
289 style: SemanticStyle::Body,
290 link: None,
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use mdsee_core::SourceDocument;
297
298 use super::*;
299 use crate::LayoutOptions;
300
301 fn parse_table(markdown: &str) -> mdsee_core::Table {
302 let source = SourceDocument {
303 content: markdown.to_string(),
304 origin: mdsee_core::Origin::Stdin {
305 cwd: std::env::temp_dir(),
306 },
307 };
308 let document = mdsee_core::parse(&source).unwrap();
309 match document.blocks.into_iter().next() {
310 Some(mdsee_core::Block::Table(table)) => table,
311 other => panic!("expected table, got {other:?}"),
312 }
313 }
314
315 fn line_texts(layout: &TableLayout) -> Vec<String> {
316 layout
317 .lines
318 .iter()
319 .map(|line| {
320 line.spans
321 .iter()
322 .map(|span| span.content.as_str())
323 .collect::<String>()
324 })
325 .collect()
326 }
327
328 #[test]
329 fn simple_table_fits_width() {
330 let table = parse_table("| a | b |\n| --- | --- |\n| 1 | 2 |\n");
331 let layout = TableLayoutEngine::layout(&table, 40);
332 let lines = line_texts(&layout);
333 assert_eq!(
334 lines,
335 [
336 "┌───┬───┐",
337 "│ a │ b │",
338 "├───┼───┤",
339 "│ 1 │ 2 │",
340 "└───┴───┘",
341 ]
342 );
343 }
344
345 #[test]
346 fn alignment_is_applied() {
347 let table =
348 parse_table("| Left | Center | Right |\n| :--- | :----: | ----: |\n| a | b | c |\n");
349 let layout = TableLayoutEngine::layout(&table, 40);
350 let lines = line_texts(&layout);
351 assert!(
353 lines[3].contains("│ a │ b │ c │"),
354 "got: {}",
355 lines[3]
356 );
357 }
358
359 #[test]
360 fn wide_columns_shrink_by_slack() {
361 let markdown =
363 "| s | verylongcolumnheader | s |\n| --- | --- | --- |\n| a | bbbbbbbbbbbbbbbb | c |\n";
364 let table = parse_table(markdown);
365 let layout = TableLayoutEngine::layout(&table, 30);
366 for line in line_texts(&layout) {
367 assert!(
368 UnicodeWidthStr::width(line.as_str()) <= 30,
369 "line too wide: {line}"
370 );
371 }
372 }
373
374 #[test]
375 fn cells_wrap_when_narrow() {
376 let markdown =
377 "| lang | description |\n| --- | --- |\n| rust | a systems programming language that is safe |\n";
378 let table = parse_table(markdown);
379 let layout = TableLayoutEngine::layout(&table, 34);
380 for line in line_texts(&layout) {
381 assert!(
382 UnicodeWidthStr::width(line.as_str()) <= 34,
383 "line too wide: {line}"
384 );
385 }
386 assert_eq!(layout.lines.len(), 6);
388 }
389
390 #[test]
391 fn japanese_table_wraps() {
392 let markdown = "| 名前 | 説明 |\n| --- | --- |\n| 日本語テキスト | これはとても長い日本語の説明文です |\n";
394 let table = parse_table(markdown);
395 let layout = TableLayoutEngine::layout(&table, 30);
396 for line in line_texts(&layout) {
397 assert!(
398 UnicodeWidthStr::width(line.as_str()) <= 30,
399 "line too wide: {line}"
400 );
401 }
402 let joined: String = line_texts(&layout).join("");
404 assert!(joined.contains("日本語"));
405 assert!(joined.contains("説明"));
406 }
407
408 #[test]
409 fn empty_table_produces_nothing() {
410 let table = Table {
411 alignments: vec![],
412 header: vec![],
413 rows: vec![],
414 span: Default::default(),
415 id: mdsee_core::BlockId::new(0),
416 };
417 let layout = TableLayoutEngine::layout(&table, 40);
418 assert!(layout.lines.is_empty());
419 }
420
421 #[test]
422 fn layout_dispatch_produces_table_block() {
423 let source = SourceDocument {
424 content: "| a |\n| --- |\n| b |\n".to_string(),
425 origin: mdsee_core::Origin::Stdin {
426 cwd: std::env::temp_dir(),
427 },
428 };
429 let document = mdsee_core::parse(&source).unwrap();
430 let laid = crate::layout(&document, &LayoutOptions::default()).unwrap();
431 assert!(matches!(laid.blocks[0], LayoutBlock::Table(_)));
432 }
433}