Skip to main content

mdsee_layout/
table.rs

1//! Table layout engine(design.md §30〜§32)。
2//!
3//! アルゴリズム(§30):
4//! 1. 各セルのminimum widthを取得
5//! 2. preferred widthを取得
6//! 3. columnごとのpreferred width決定
7//! 4. 全幅がcontent_widthを超える場合縮小
8//! 5. 各cellをwrap
9//! 6. row height決定
10//! 7. border描画
11//!
12//! 縮小時は `preferred - min` の余裕が大きいcolumnから削る(§31)。
13
14use 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/// 表layout engine(§30)。独立moduleにする設計に従う。
22#[derive(Debug, Clone, Copy, Default)]
23pub struct TableLayoutEngine;
24
25impl TableLayoutEngine {
26    /// Table ASTを幅 `width` に収まる表へlayoutする。
27    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        // 行を正規化(列数に満たないcellは空で埋める)
38        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        // (1)(2) 各columnのmin / preferred
47        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        // (3)(4) assigned決定。区切りを除いた列幅の合計は avail を超えない
58        let overhead = 3 * ncols + 1; // "│ x │ y │" の罫線と空白
59        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        // (5)(6) 各cellをwrapし、行高を揃えて行を合成
64        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        // (7) border描画
73        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
86/// Table blockを `LayoutBlock::Table` へ変換するentry point。
87pub(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
108/// §31: 余裕(assigned - min)が大きいcolumnから1列ずつ削る。
109fn 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    // min まで削っても収まらない場合は1列分まで強制する(hard split)
124    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
138/// セルのminimum width(§30 step 1)。
139///
140/// 空白とCJK境界で区切った最長runの幅。英語のwordは切れない前提の幅で、
141/// CJK連続は1文字ずつ切れるため最大grapheme幅になる。
142fn 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
165/// セルのpreferred width(§30 step 2)。1行で表示した場合の幅。
166fn 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
180/// inline列の平文。表示幅計算のみに使う。
181pub(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
211/// wrap済みcell列を行へ合成する。行高は各行の最大(§30 step 6)。
212fn 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
239/// セルの1行をalignmentどおりにpaddingする(§32)。
240fn 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        // header幅(Left=4, Center=6, Right=5)でalignmentどおりにpadされる
352        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        // 3列で1列だけ極端に長い。長い列が削られる
362        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        // ┌ + header + ├ + wrapされたdata行2 + └ = 6行
387        assert_eq!(layout.lines.len(), 6);
388    }
389
390    #[test]
391    fn japanese_table_wraps() {
392        // §84: 日本語表
393        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        // 分断しても連結すると元テキストを保つ
403        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}