Skip to main content

pptx_edit/
table.rs

1use pptx_parse::{GraphicFrameData, Table, TableRowSource, TableStructure};
2use serde::{Deserialize, Serialize};
3use yrs::types::{AsPrelim, text::YChange};
4use yrs::{Any, In, Map, Out, ReadTxn, Text, TextRef, Transact};
5
6use crate::deck::{map_number, map_string, map_string_array, required_map, shape_ref};
7use crate::story::{materialize_paragraph_joins, snapshot_story, story_ref};
8use crate::{DeckSession, EditCtx, EditError, EditResult, PARA_ID, STORIES, ShapeSnapshot};
9
10#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub enum TableInsertPosition {
13    Before,
14    After,
15}
16
17#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub enum TableColumnWidth {
20    #[default]
21    Split,
22    Copy,
23}
24
25#[derive(Clone, Debug, Deserialize, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct TableReceipt {
28    pub slide_id: String,
29    pub shape_id: String,
30    pub row_count: usize,
31    pub column_count: usize,
32    pub new_cell_refs: Vec<String>,
33}
34
35#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub enum TableEdit {
38    InsertRow,
39    DeleteRow,
40    InsertColumn,
41    DeleteColumn,
42}
43
44enum Action<'a> {
45    Insert(TableInsertPosition, &'a [String], TableColumnWidth),
46    Delete,
47}
48
49impl DeckSession {
50    pub fn insert_table_row(
51        &self,
52        context: &EditCtx,
53        slide_id: &str,
54        shape_id: &str,
55        index: u32,
56        position: TableInsertPosition,
57        cells: &[String],
58    ) -> EditResult<TableReceipt> {
59        self.edit_table(
60            context,
61            slide_id,
62            shape_id,
63            index,
64            true,
65            Action::Insert(position, cells, TableColumnWidth::Split),
66        )
67    }
68
69    pub fn delete_table_row(
70        &self,
71        context: &EditCtx,
72        slide_id: &str,
73        shape_id: &str,
74        index: u32,
75    ) -> EditResult<TableReceipt> {
76        self.edit_table(context, slide_id, shape_id, index, true, Action::Delete)
77    }
78
79    pub fn insert_table_column(
80        &self,
81        context: &EditCtx,
82        slide_id: &str,
83        shape_id: &str,
84        index: u32,
85        position: TableInsertPosition,
86        cells: &[String],
87    ) -> EditResult<TableReceipt> {
88        self.insert_table_column_with_width(
89            context,
90            slide_id,
91            shape_id,
92            index,
93            position,
94            cells,
95            TableColumnWidth::Split,
96        )
97    }
98
99    #[allow(clippy::too_many_arguments)]
100    pub fn insert_table_column_with_width(
101        &self,
102        context: &EditCtx,
103        slide_id: &str,
104        shape_id: &str,
105        index: u32,
106        position: TableInsertPosition,
107        cells: &[String],
108        width: TableColumnWidth,
109    ) -> EditResult<TableReceipt> {
110        self.edit_table(
111            context,
112            slide_id,
113            shape_id,
114            index,
115            false,
116            Action::Insert(position, cells, width),
117        )
118    }
119
120    pub fn delete_table_column(
121        &self,
122        context: &EditCtx,
123        slide_id: &str,
124        shape_id: &str,
125        index: u32,
126    ) -> EditResult<TableReceipt> {
127        self.edit_table(context, slide_id, shape_id, index, false, Action::Delete)
128    }
129
130    /// Runs every check of a table structure edit without changing the deck.
131    pub fn check_table_edit(
132        &self,
133        edit: TableEdit,
134        slide_id: &str,
135        shape_id: &str,
136        index: u32,
137        position: TableInsertPosition,
138        cells: &[String],
139    ) -> EditResult<()> {
140        self.check_table_edit_with_width(
141            edit,
142            slide_id,
143            shape_id,
144            index,
145            position,
146            cells,
147            TableColumnWidth::Split,
148        )
149    }
150
151    #[allow(clippy::too_many_arguments)]
152    pub fn check_table_edit_with_width(
153        &self,
154        edit: TableEdit,
155        slide_id: &str,
156        shape_id: &str,
157        index: u32,
158        position: TableInsertPosition,
159        cells: &[String],
160        width: TableColumnWidth,
161    ) -> EditResult<()> {
162        let (row_axis, action) = match edit {
163            TableEdit::InsertRow => (
164                true,
165                Action::Insert(position, cells, TableColumnWidth::Split),
166            ),
167            TableEdit::DeleteRow => (true, Action::Delete),
168            TableEdit::InsertColumn => (false, Action::Insert(position, cells, width)),
169            TableEdit::DeleteColumn => (false, Action::Delete),
170        };
171        self.plan_table_edit(
172            &EditCtx::local("check"),
173            slide_id,
174            shape_id,
175            index,
176            row_axis,
177            action,
178            false,
179        )
180        .map(|_| ())
181    }
182
183    fn edit_table(
184        &self,
185        context: &EditCtx,
186        slide_id: &str,
187        shape_id: &str,
188        index: u32,
189        row_axis: bool,
190        action: Action<'_>,
191    ) -> EditResult<TableReceipt> {
192        self.plan_table_edit(context, slide_id, shape_id, index, row_axis, action, true)?
193            .ok_or_else(|| EditError::InvalidState("table edit was not applied".into()))
194    }
195
196    #[allow(clippy::too_many_arguments)]
197    fn plan_table_edit(
198        &self,
199        context: &EditCtx,
200        slide_id: &str,
201        shape_id: &str,
202        index: u32,
203        row_axis: bool,
204        action: Action<'_>,
205        apply: bool,
206    ) -> EditResult<Option<TableReceipt>> {
207        if self.collaborative.get() {
208            return Err(EditError::CollaborativeStructureOperation);
209        }
210        let snapshot = self.snapshot()?;
211        let slide = snapshot
212            .slides
213            .iter()
214            .find(|slide| slide.id == slide_id)
215            .ok_or_else(|| EditError::SlideNotFound(slide_id.into()))?;
216        let shape = find_shape(&slide.shapes, shape_id)
217            .ok_or_else(|| EditError::ShapeNotFound(shape_id.into()))?;
218        let Some(GraphicFrameData::Table(mut table)) = shape.graphic.clone() else {
219            return Err(invalid("shape must identify a table from the outline"));
220        };
221        validate_table(&table)?;
222        validate_structure(shape)?;
223        let rows = table.rows.len();
224        let columns = table.grid.len();
225        if shape.text_stories.len() != rows * columns {
226            return Err(invalid("table cell text stories do not match its grid"));
227        }
228        let length = if row_axis { rows } else { columns };
229        let field = if row_axis { "row" } else { "column" };
230        let index = index as usize;
231        if index >= length {
232            return Err(invalid(format!(
233                "{field} index {index} is out of range; valid range is 0..{}",
234                length - 1
235            )));
236        }
237        let mut sources = shape
238            .table_structure
239            .clone()
240            .unwrap_or_else(|| TableStructure {
241                generation: String::new(),
242                story_keys: shape
243                    .text_stories
244                    .iter()
245                    .map(|story| story.id.clone())
246                    .collect(),
247                grid: table.grid.clone(),
248                columns: (0..columns).collect(),
249                copied_columns: vec![false; columns],
250                rows: table
251                    .rows
252                    .iter()
253                    .enumerate()
254                    .map(|(r, row)| TableRowSource {
255                        source_row: r,
256                        copied: false,
257                        copied_cells: vec![false; columns],
258                        height: row.height,
259                        cells: (0..columns).map(|c| [r, c]).collect(),
260                    })
261                    .collect(),
262            });
263        let mut donors: Vec<Vec<(usize, Option<&str>)>> = (0..rows)
264            .map(|r| (0..columns).map(|c| (r * columns + c, None)).collect())
265            .collect();
266        let mut refit_width = false;
267        let delta = match action {
268            Action::Delete => {
269                if length == 1 {
270                    return Err(invalid(format!("cannot delete the last {field}")));
271                }
272                let delta = if row_axis {
273                    -table.rows[index].height
274                } else {
275                    -table.grid[index]
276                };
277                if row_axis {
278                    table.rows.remove(index);
279                    sources.rows.remove(index);
280                    donors.remove(index);
281                } else {
282                    table.grid.remove(index);
283                    sources.columns.remove(index);
284                    sources.copied_columns.remove(index);
285                    for ((row, source), donor) in table
286                        .rows
287                        .iter_mut()
288                        .zip(&mut sources.rows)
289                        .zip(&mut donors)
290                    {
291                        row.cells.remove(index);
292                        source.cells.remove(index);
293                        source.copied_cells.remove(index);
294                        donor.remove(index);
295                    }
296                }
297                delta
298            }
299            Action::Insert(position, cells, width_mode) => {
300                let cell_count = if row_axis { columns } else { rows };
301                if cells.len() > cell_count {
302                    return Err(invalid(format!(
303                        "cells has {} entries; supply at most {cell_count}",
304                        cells.len()
305                    )));
306                }
307                for text in cells {
308                    if text.contains(['\r', '\n']) {
309                        return Err(invalid(
310                            "cells text must not contain line breaks; give each cell one line of text",
311                        ));
312                    }
313                    crate::model::validate_xml_text(text)?;
314                    if text.encode_utf16().count() > 16_000 {
315                        return Err(invalid("cells text must fit in 16000 UTF-16 units"));
316                    }
317                }
318                if rows
319                    .saturating_add(1)
320                    .saturating_mul(columns.saturating_add(1))
321                    > 100_000
322                {
323                    return Err(invalid("table would exceed the 100000-cell edit limit"));
324                }
325                let at = index + usize::from(matches!(position, TableInsertPosition::After));
326                if row_axis {
327                    let row = table.rows[index].clone();
328                    let mut source = sources.rows[index].clone();
329                    source.copied = true;
330                    source.copied_cells.fill(true);
331                    table.rows.insert(at, row);
332                    sources.rows.insert(at, source);
333                    donors.insert(
334                        at,
335                        (0..columns)
336                            .map(|c| {
337                                (
338                                    index * columns + c,
339                                    Some(cells.get(c).map_or("", String::as_str)),
340                                )
341                            })
342                            .collect(),
343                    );
344                } else {
345                    let width = table.grid[index];
346                    match width_mode {
347                        TableColumnWidth::Split => {
348                            if width < 2 {
349                                return Err(invalid(
350                                    "column width must be at least 2 EMU to split it",
351                                ));
352                            }
353                            table.grid[index] = width - width / 2;
354                            table.grid.insert(at, width / 2);
355                        }
356                        TableColumnWidth::Copy => {
357                            table.grid.insert(at, width);
358                            refit_width = true;
359                        }
360                    }
361                    sources.columns.insert(at, sources.columns[index]);
362                    sources.copied_columns.insert(at, true);
363                    for (r, ((row, source), donor)) in table
364                        .rows
365                        .iter_mut()
366                        .zip(&mut sources.rows)
367                        .zip(&mut donors)
368                        .enumerate()
369                    {
370                        row.cells.insert(at, row.cells[index].clone());
371                        source.cells.insert(at, source.cells[index]);
372                        source.copied_cells.insert(at, true);
373                        donor.insert(
374                            at,
375                            (
376                                r * columns + index,
377                                Some(cells.get(r).map_or("", String::as_str)),
378                            ),
379                        );
380                    }
381                }
382                if row_axis {
383                    table.rows[index].height
384                } else {
385                    0
386                }
387            }
388        };
389        sources.grid.clone_from(&table.grid);
390        let (width, height) = if row_axis {
391            (
392                shape.width,
393                resize(shape.height, delta, table.rows.iter().map(|row| row.height))?,
394            )
395        } else {
396            (
397                if refit_width {
398                    checked_sum(table.grid.iter().copied())?
399                } else {
400                    resize(shape.width, delta, table.grid.iter().copied())?
401                },
402                shape.height,
403            )
404        };
405        let theme = pptx_parse::slide_theme(
406            self.package(),
407            slide.source_part_path.as_deref(),
408            slide.layout_part_path.as_deref(),
409        );
410        let txn = self.doc.transact();
411        let mut copies = Vec::new();
412        for row in &donors {
413            for &(donor, text) in row {
414                let source_id = &shape.text_stories[donor].id;
415                let key = storage_key(&txn, source_id)?;
416                let copy = if text.is_some() {
417                    let story = story_ref(&txn, source_id)?;
418                    let mut snapshot = snapshot_story(&story, &txn, source_id)?;
419                    if let Some(GraphicFrameData::Table(template)) = &shape.graphic {
420                        let body = &template.rows[donor / columns].cells[donor % columns].text;
421                        if body
422                            .paragraphs
423                            .iter()
424                            .flat_map(|p| &p.runs)
425                            .any(|run| run.field_id.is_some() || run.field_type.is_some())
426                        {
427                            return Err(invalid(
428                                "cannot copy a template cell containing text fields",
429                            ));
430                        }
431                        for (index, paragraph) in snapshot.paragraphs.iter_mut().enumerate() {
432                            if paragraph.runs.is_empty()
433                                && let Some(properties) = body
434                                    .paragraphs
435                                    .get(index)
436                                    .or_else(|| body.paragraphs.last())
437                                    .and_then(|p| {
438                                        p.runs
439                                            .first()
440                                            .map(|run| &run.properties)
441                                            .or(p.end_properties.as_ref())
442                                    })
443                            {
444                                paragraph.runs.push(crate::TextRunSnapshot {
445                                    text: String::new(),
446                                    style: crate::story::style_from_run_properties(
447                                        properties,
448                                        Some(&theme),
449                                    ),
450                                });
451                            }
452                        }
453                    }
454                    Some((In::Text(story.as_prelim(&txn)), snapshot))
455                } else {
456                    None
457                };
458                copies.push((source_id.clone(), key, text, copy));
459            }
460        }
461        let stories = required_map(&txn, STORIES)?;
462        let target = shape_ref(&txn, shape_id)?;
463        drop(txn);
464        if !apply {
465            return Ok(None);
466        }
467        let parent = shape
468            .table_structure
469            .as_ref()
470            .map_or("source", |structure| structure.generation.as_str());
471        let generation = self.next_id("table");
472        sources.generation.clone_from(&generation);
473        let ids: Vec<_> = (0..copies.len())
474            .map(|i| {
475                format!(
476                    "story:{shape_id}:table:{}:{}:{generation}",
477                    i / table.grid.len(),
478                    i % table.grid.len()
479                )
480            })
481            .collect();
482        sources.story_keys = copies
483            .iter()
484            .enumerate()
485            .map(|(i, (_, key, text, _))| {
486                if text.is_some() {
487                    format!("cell:{generation}:{i}")
488                } else {
489                    key.clone()
490                }
491            })
492            .collect();
493        let graphic_json = serde_json::to_string(&GraphicFrameData::Table(table.clone()))
494            .map_err(|error| EditError::Json(error.to_string()))?;
495        let source_json =
496            serde_json::to_string(&sources).map_err(|error| EditError::Json(error.to_string()))?;
497        let mut new_cell_refs = Vec::new();
498        let client = yrs::ClientID::new(self.client_id);
499        let start = self.doc.transact().state_vector().get(&client);
500        self.automatic_undo_barrier();
501        let mut txn = self.transact_for(context);
502        for (i, (source_id, source_key, text, copy)) in copies.into_iter().enumerate() {
503            let Some((value, snapshot)) = copy else {
504                continue;
505            };
506            let key = &sources.story_keys[i];
507            let story = stories
508                .insert(&mut txn, key.as_str(), value)
509                .cast::<TextRef>()
510                .map_err(|_| EditError::StoryNotFound(key.clone()))?;
511            materialize_paragraph_joins(&story, &mut txn, &source_id)?;
512            let prefix = format!("para:{source_key}:");
513            let mut paragraph_index = 0;
514            for diff in story.diff(&txn, YChange::identity) {
515                if let Out::YMap(marker) = diff.insert {
516                    let old = map_string(&marker, &txn, PARA_ID).unwrap_or_default();
517                    let suffix = old.strip_prefix(&prefix).unwrap_or(&old);
518                    marker.insert(&mut txn, PARA_ID, format!("para:{key}:{suffix}"));
519                    if text.is_some()
520                        && let Some(run) = snapshot.paragraphs[paragraph_index].runs.first()
521                    {
522                        marker.insert(
523                            &mut txn,
524                            "emptyStyleJson",
525                            serde_json::to_string(&run.style)
526                                .map_err(|error| EditError::Json(error.to_string()))?,
527                        );
528                    }
529                    paragraph_index += 1;
530                }
531            }
532            if let Some(text) = text {
533                let mut offset = 0;
534                let mut strings = Vec::new();
535                for diff in story.diff(&txn, YChange::identity) {
536                    match diff.insert {
537                        Out::Any(Any::String(value)) => {
538                            let length = value.encode_utf16().count() as u32;
539                            strings.push((offset, length));
540                            offset += length;
541                        }
542                        _ => offset += 1,
543                    }
544                }
545                for (start, length) in strings.into_iter().rev() {
546                    story.remove_range(&mut txn, start, length);
547                }
548                let paragraph_count = story.len(&txn);
549                if paragraph_count > 1 {
550                    story.remove_range(&mut txn, 1, paragraph_count - 1);
551                }
552                if !text.is_empty() {
553                    let style = snapshot
554                        .paragraphs
555                        .first()
556                        .and_then(|paragraph| paragraph.runs.first())
557                        .map(|run| run.style.clone())
558                        .unwrap_or_default();
559                    crate::story::insert_styled_text(&story, &mut txn, 0, text, &style);
560                }
561                let snapshot = snapshot_story(&story, &txn, key)?;
562                new_cell_refs.push(project_paragraph_id(
563                    &snapshot.paragraphs[0].id,
564                    key,
565                    &ids[i],
566                ));
567            }
568        }
569        target.insert(&mut txn, format!("tableEdit:{generation}"), parent);
570        target.insert(&mut txn, "tableGeneration", generation.as_str());
571        target.insert(&mut txn, "tableColumnCount", table.grid.len() as f64);
572        target.insert(
573            &mut txn,
574            "tableStoryKeys",
575            Any::Array(
576                sources
577                    .story_keys
578                    .iter()
579                    .map(|key| Any::from(key.as_str()))
580                    .collect::<Vec<_>>()
581                    .into(),
582            ),
583        );
584        target.insert(&mut txn, "graphicJson", graphic_json);
585        target.insert(&mut txn, "tableStructureJson", source_json);
586        target.insert(
587            &mut txn,
588            "textStories",
589            Any::Array(
590                ids.iter()
591                    .map(|id| Any::from(id.as_str()))
592                    .collect::<Vec<_>>()
593                    .into(),
594            ),
595        );
596        target.insert(&mut txn, "width", width as f64);
597        target.insert(&mut txn, "height", height as f64);
598        drop(txn);
599        self.automatic_undo_barrier();
600        let end = self.doc.transact().state_vector().get(&client);
601        if end > start {
602            let mut changes = yrs::IdSet::new();
603            changes.insert(yrs::ID::new(client, start), end - start);
604            self.undo.borrow_mut().track_table(shape_id, changes);
605        }
606        self.table_edits
607            .borrow_mut()
608            .entry(shape_id.to_owned())
609            .or_default()
610            .push(generation);
611        Ok(Some(TableReceipt {
612            slide_id: slide_id.into(),
613            shape_id: shape_id.into(),
614            row_count: table.rows.len(),
615            column_count: table.grid.len(),
616            new_cell_refs,
617        }))
618    }
619}
620
621fn resize(size: i64, delta: i64, parts: impl Iterator<Item = i64>) -> EditResult<i64> {
622    let floor = checked_sum(parts)?;
623    Ok(size
624        .checked_add(delta)
625        .filter(|size| *size <= 1_000_000_000_000_000)
626        .map_or(floor, |size| size.max(floor)))
627}
628
629fn checked_sum(mut values: impl Iterator<Item = i64>) -> EditResult<i64> {
630    values
631        .try_fold(0_i64, |sum, value| sum.checked_add(value))
632        .filter(|sum| *sum > 0 && *sum <= 1_000_000_000_000_000)
633        .ok_or_else(|| invalid("table geometry exceeds the supported range"))
634}
635
636pub(crate) fn validate_table(table: &Table) -> EditResult<()> {
637    if table.rows.is_empty()
638        || table.grid.is_empty()
639        || table.grid.iter().any(|w| *w <= 0)
640        || table
641            .rows
642            .iter()
643            .any(|row| row.height <= 0 || row.cells.len() != table.grid.len())
644    {
645        return Err(invalid(
646            "table must have a rectangular grid with positive column widths and row heights",
647        ));
648    }
649    if table
650        .rows
651        .iter()
652        .flat_map(|row| &row.cells)
653        .any(|cell| cell.merged || cell.grid_span != 1 || cell.row_span != 1)
654    {
655        return Err(invalid(
656            "merged table cells are unsupported for structural edits; unmerge the table first",
657        ));
658    }
659    Ok(())
660}
661
662fn find_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
663    shapes.iter().find_map(|shape| {
664        if shape.id == id {
665            Some(shape)
666        } else {
667            find_shape(&shape.children, id)
668        }
669    })
670}
671
672fn invalid(message: impl Into<String>) -> EditError {
673    EditError::InvalidTable(message.into())
674}
675
676pub(crate) fn source_cell(shape: &ShapeSnapshot, story_id: &str) -> Option<[usize; 2]> {
677    let pptx_parse::TextTarget::TableCell { row, cell } =
678        crate::save::text_target(story_id, &shape.id).ok()?
679    else {
680        return None;
681    };
682    match &shape.table_structure {
683        Some(structure) => structure.rows.get(row)?.cells.get(cell).copied(),
684        None => Some([row, cell]),
685    }
686}
687
688pub(crate) fn validate_structure(shape: &ShapeSnapshot) -> EditResult<()> {
689    let Some(structure) = &shape.table_structure else {
690        return Ok(());
691    };
692    let Some(GraphicFrameData::Table(table)) = &shape.graphic else {
693        return Err(invalid("table structure requires a table"));
694    };
695    validate_table(table)?;
696    if structure.grid != table.grid
697        || structure.columns.len() != table.grid.len()
698        || structure.copied_columns.len() != table.grid.len()
699        || structure.rows.len() != table.rows.len()
700        || structure.story_keys.len() != shape.text_stories.len()
701        || structure
702            .story_keys
703            .iter()
704            .collect::<std::collections::HashSet<_>>()
705            .len()
706            != structure.story_keys.len()
707        || shape.text_stories.len() != table.rows.len() * table.grid.len()
708        || structure.rows.iter().zip(&table.rows).any(|(source, row)| {
709            source.height != row.height
710                || source.cells.len() != row.cells.len()
711                || source.copied_cells.len() != row.cells.len()
712        })
713    {
714        return Err(invalid("table grid, rows and text stories must agree"));
715    }
716    for (i, story) in shape.text_stories.iter().enumerate() {
717        let expected = format!(
718            "story:{}:table:{}:{}:",
719            shape.id,
720            i / table.grid.len(),
721            i % table.grid.len()
722        );
723        if !story.id.starts_with(&expected) {
724            return Err(invalid(
725                "table text stories must match current cell positions",
726            ));
727        }
728    }
729    Ok(())
730}
731
732pub(crate) fn storage_key<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<String> {
733    let Some((shape_id, address)) = story_id
734        .strip_prefix("story:")
735        .and_then(|id| id.split_once(":table:"))
736    else {
737        return Ok(story_id.into());
738    };
739    let shape = shape_ref(txn, shape_id)?;
740    let Some(generation) = map_string(&shape, txn, "tableGeneration") else {
741        return Ok(story_id.into());
742    };
743    let missing = || EditError::StoryNotFound(story_id.into());
744    let (row, rest) = address.split_once(':').ok_or_else(missing)?;
745    let (column, suffix) = rest.split_once(':').ok_or_else(missing)?;
746    let row: usize = row.parse().map_err(|_| missing())?;
747    let column: usize = column.parse().map_err(|_| missing())?;
748    let columns = map_number(&shape, txn, "tableColumnCount")
749        .filter(|n| n.fract() == 0.0 && *n > 0.0)
750        .ok_or_else(missing)? as usize;
751    if suffix != generation
752        || column >= columns
753        || address != format!("{row}:{column}:{generation}")
754    {
755        return Err(missing());
756    }
757    let at = row
758        .checked_mul(columns)
759        .and_then(|at| at.checked_add(column))
760        .ok_or_else(missing)?;
761    let Some(Out::Any(Any::Array(keys))) = shape.get(txn, "tableStoryKeys") else {
762        return Err(missing());
763    };
764    match keys.get(at) {
765        Some(Any::String(key)) => Ok(key.to_string()),
766        _ => Err(missing()),
767    }
768}
769
770pub(crate) fn project_paragraph_id(id: &str, storage_key: &str, public_id: &str) -> String {
771    if storage_key == public_id {
772        return id.into();
773    }
774    match id.strip_prefix(&format!("para:{storage_key}:")) {
775        Some(suffix) => format!("para:{public_id}:{suffix}"),
776        None => format!("para:{public_id}:id:{id}"),
777    }
778}
779
780pub(crate) fn validate_history<T: ReadTxn>(txn: &T, shape: &ShapeSnapshot) -> EditResult<()> {
781    let Some(structure) = &shape.table_structure else {
782        return Ok(());
783    };
784    let map = shape_ref(txn, &shape.id)?;
785    if map_string(&map, txn, "tableGeneration").as_deref() != Some(structure.generation.as_str())
786        || map_number(&map, txn, "tableColumnCount") != Some(structure.grid.len() as f64)
787        || map_string_array(&map, txn, "tableStoryKeys")? != structure.story_keys
788    {
789        return Err(invalid("table story storage and geometry must agree"));
790    }
791    Ok(())
792}
793
794#[derive(Debug, Default, PartialEq)]
795pub(crate) struct EditLog {
796    generation: Option<String>,
797    pub(crate) edits: std::collections::BTreeMap<String, String>,
798}
799
800impl EditLog {
801    /// The current generation and every structural edit it descends from.
802    pub(crate) fn lineage(&self) -> std::collections::HashSet<&str> {
803        let mut lineage = std::collections::HashSet::new();
804        let mut current = self.generation.as_deref();
805        while let Some(generation) = current {
806            if !lineage.insert(generation) {
807                break;
808            }
809            current = self.edits.get(generation).map(String::as_str);
810        }
811        lineage
812    }
813}
814
815pub(crate) fn edit_log(doc: &yrs::Doc, shape_id: &str) -> EditLog {
816    let txn = doc.transact();
817    let Ok(shape) = shape_ref(&txn, shape_id) else {
818        return EditLog::default();
819    };
820    EditLog {
821        generation: map_string(&shape, &txn, "tableGeneration"),
822        edits: shape
823            .iter(&txn)
824            .filter_map(|(key, value)| {
825                let id = key.strip_prefix("tableEdit:")?;
826                let Out::Any(Any::String(parent)) = value else {
827                    return None;
828                };
829                Some((id.to_owned(), parent.to_string()))
830            })
831            .collect(),
832    }
833}