diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs
--- a/crates/doc/src/table.rs
+++ b/crates/doc/src/table.rs
@@ -393,10 +393,44 @@ impl CellRange {
     }
 }
 
+/// Most rows a table holds, as InDesign allows.
+pub const MAX_TABLE_ROWS: usize = 10_000;
+/// Most columns a table holds, as InDesign allows.
+pub const MAX_TABLE_COLS: usize = 1_000;
+/// Most cells a table holds, whatever its shape.
+///
+/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the
+/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an
+/// IDML story holding one `<Table>` with 5,000 `<Row/>` and 5,000 `<Column/>` -- about 1 KB
+/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts
+/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open
+/// document's unsaved work goes with it (#1583).
+pub const MAX_TABLE_CELLS: usize = 250_000;
+
+/// The rows and columns a table of `rows × cols` is actually given, inside the limits above.
+///
+/// Columns are clamped first and rows take what is left of the cell budget, because a table is
+/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its
+/// rows loses less of it than the other way round. Callers that read a file report the
+/// difference; the counts are a document's claim, not something to trust.
+pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) {
+    let cols = cols.clamp(1, MAX_TABLE_COLS);
+    let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1));
+    (rows, cols)
+}
+
 impl Table {
     /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`.
+    ///
+    /// The shape is clamped to [`clamped_size`]: the counts reach this from IDML and DOCX files,
+    /// where they are whatever the file says (#1583).
     pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table {
-        let cols = cols.max(1);
+        // Header, body and footer rows share one budget: the total is clamped first, then the
+        // header and footer take what they can of it, and the body keeps at least one row.
+        let (total, cols) = clamped_size(body_rows.max(1).saturating_add(header).saturating_add(footer), cols);
+        let header = header.min(total.saturating_sub(1));
+        let footer = footer.min(total.saturating_sub(header).saturating_sub(1));
+        let body_rows = total.saturating_sub(header).saturating_sub(footer).max(1);
         let mut rows = Vec::new();
         rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() }));
         rows.extend((0..body_rows.max(1)).map(|_| Row::default()));
@@ -1273,6 +1307,63 @@ impl Story {
 
 #[cfg(test)]
 mod tests {
+    /// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so
+    /// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the
+    /// process rather than failing, taking every open document's unsaved work with it (#1583).
+    #[test]
+    fn a_table_larger_than_the_limits_is_clamped_not_allocated() {
+        // The reported shape: 5,000 rows and 5,000 columns.
+        let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0);
+        assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit");
+        assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len());
+        assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular");
+        // What it would have been: 25,000,000 cells at 1,360 bytes each.
+        assert!(25_000_000 * std::mem::size_of::<Cell>() > 30_000_000_000, "the size this test is about");
+        // Each of these tables is the cell budget, about 340 MB. Freed before the next one is
+        // built, so the test's peak is one table and not all of them -- a constrained runner
+        // would otherwise hold 680 MB here (noted in review).
+        drop(t);
+
+        // A long table keeps its rows while it fits the budget (the budget itself is checked by
+        // `clamped_size`, which allocates nothing).
+        let long = Table::new(2, 10_000, 5, 0, 0, 500.0);
+        assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched");
+        assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget");
+
+        // Past the row limit the rows are clamped too.
+        let longer = Table::new(3, 50_000, 2, 0, 0, 100.0);
+        assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit");
+
+        // Header, body and footer share the budget, and the table still has a body.
+        let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0);
+        assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len());
+        assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len());
+        assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives");
+        assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives");
+        assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row");
+        drop(with_parts);
+
+        // A header and footer bigger than the whole budget still leave a body row.
+        let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0);
+        assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len());
+        assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives");
+
+        // An ordinary table is exactly what it was.
+        let plain = Table::new(5, 4, 4, 1, 1, 400.0);
+        assert_eq!((plain.rows.len(), plain.columns.len(), plain.cells.len()), (6, 4, 24));
+    }
+
+    /// `clamped_size` is the rule the importers report against.
+    #[test]
+    fn clamped_size_keeps_the_shape_it_can() {
+        assert_eq!(clamped_size(4, 4), (4, 4));
+        assert_eq!(clamped_size(0, 0), (1, 1), "a table has at least one cell");
+        assert_eq!(clamped_size(10, MAX_TABLE_COLS + 500), (10, MAX_TABLE_COLS));
+        assert_eq!(clamped_size(MAX_TABLE_ROWS + 1, 1), (MAX_TABLE_ROWS, 1));
+        let (r, c) = clamped_size(5_000, 5_000);
+        assert!(r * c <= MAX_TABLE_CELLS && c == MAX_TABLE_COLS, "{r} x {c}");
+    }
+
     use super::*;
 
     fn story(t: &str) -> Story {
diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs
--- a/crates/idml/src/import.rs
+++ b/crates/idml/src/import.rs
@@ -2196,21 +2196,34 @@ impl<'r> Importer<'r> {
         let id = self.alloc();
         let rows: Vec<&El> = e.find_all("Row").collect();
         let cols: Vec<&El> = e.find_all("Column").collect();
-        let nr = rows.len().max(1);
-        let nc = cols.len().max(1);
+        // A file's row and column counts are its claim, and a table of them is allocated whole: a
+        // story holding one `<Table>` with 5,000 of each asked for 25M cells, around 34 GB, which
+        // aborts the process instead of failing (#1583). What is read is clamped, and the rest of
+        // this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's
+        // own counts would be out of bounds.
+        let (nr, nc) = designcraft_doc::table::clamped_size(rows.len().max(1), cols.len().max(1));
+        if nr < rows.len() || nc < cols.len() {
+            self.warnings.push(format!(
+                "a table of {} rows and {} columns was read as {nr} x {nc}: more than a table can hold",
+                rows.len(),
+                cols.len()
+            ));
+        }
+        let rows = &rows[..nr.min(rows.len())];
+        let cols = &cols[..nc.min(cols.len())];
         let mut t = Table::new(id, nr, nc, 0, 0, 0.0);
         t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") {
             designcraft_doc::TextDirection::RightToLeft
         } else {
             designcraft_doc::TextDirection::LeftToRight
         };
-        for (r, re) in rows.iter().enumerate() {
+        for (row, re) in t.rows.iter_mut().zip(rows.iter()) {
             let auto = re.boolean("AutoGrow").unwrap_or(true);
-            t.rows[r].mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };
-            t.rows[r].height = if auto { re.num("MinimumHeight").unwrap_or(3.0) } else { re.num("SingleRowHeight").unwrap_or(12.0) };
+            row.mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };
+            row.height = if auto { re.num("MinimumHeight").unwrap_or(3.0) } else { re.num("SingleRowHeight").unwrap_or(12.0) };
         }
-        for (c, ce) in cols.iter().enumerate() {
-            t.columns[c].width = ce.num("SingleColumnWidth").unwrap_or(72.0).max(3.0);
+        for (column, ce) in t.columns.iter_mut().zip(cols.iter()) {
+            column.width = ce.num("SingleColumnWidth").unwrap_or(72.0).max(3.0);
         }
         let h = e.num("HeaderRowCount").unwrap_or(0.0) as usize;
         let f = e.num("FooterRowCount").unwrap_or(0.0) as usize;
