From d27eff31cd07113fabe8ce7114f44b8d0f4ace4c Mon Sep 17 00:00:00 2001 From: storybored379 Date: Mon, 12 Oct 2026 14:41:05 -0700 Subject: [PATCH 1/2] fix(import): preserve XLSX numeric and cached text values Keep small and high-precision numbers intact in table placement and data merge. Honor cached formula strings as text, including leading zeros and whitespace. Co-Authored-By: OpenAI Codex --- ROADMAP.md | 4 +++- crates/textimport/src/xlsx.rs | 35 ++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91a951616..996430b01 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Placed Excel tables and Excel data-merge records keep numeric precision, including small values, and preserve numeric-looking cached formula strings as text. No readiness percentage change. + - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index 8f243711a..99d4db0a1 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -40,6 +40,8 @@ fn value(c: &El, shared: &[String]) -> String { match c.attr("t") { Some("s") => v().trim().parse::().ok().and_then(|i| shared.get(i).cloned()).unwrap_or_default(), Some("inlineStr") => c.child("is").map(El::text).unwrap_or_default(), + // A cached formula string is text even when it looks like a number. + Some("str") => v(), Some("b") => { if v().trim() == "1" { "TRUE".into() @@ -52,7 +54,7 @@ fn value(c: &El, shared: &[String]) -> String { // Whole numbers without the trailing ".0" Excel never shows. match raw.trim().parse::() { Ok(f) if f.fract() == 0.0 && f.abs() < 1e15 => format!("{}", f as i64), - Ok(f) => format!("{}", (f * 1e10).round() / 1e10), + Ok(f) => f.to_string(), Err(_) => raw, } } @@ -334,4 +336,35 @@ mod tests { assert_eq!(rows[3][1], "1"); assert!(records(&book, Some("Missing")).is_err()); } + + fn one_value(cell: &str) -> Vec { + workbook_with(&format!( + r#"Value{cell}"# + )) + } + + #[test] + fn numeric_values_keep_their_precision_in_tables_and_records() { + for text in ["1e-12", "-0.0000000000123", "1.23456789012345", "1e300", "12.0"] { + let book = one_value(&format!(r#"{text}"#)); + let placed = crate::import("values.xlsx", &book).unwrap(); + let table = placed.story.tables.values().next().unwrap(); + let rows = crate::xlsx_records(&book, None).unwrap(); + for got in [&table.cell(1, 0).unwrap().text.text, &rows[1][0]] { + assert_eq!(got.parse::().unwrap(), text.parse::().unwrap(), "cell {text} became {got}"); + } + } + } + + #[test] + fn cached_string_values_are_not_reformatted_as_numbers() { + for text in ["00123", "1e3", "12.0", " 42 "] { + let book = one_value(&format!(r#""{text}"{text}"#)); + let placed = crate::import("values.xlsx", &book).unwrap(); + let table = placed.story.tables.values().next().unwrap(); + let rows = crate::xlsx_records(&book, None).unwrap(); + assert_eq!(table.cell(1, 0).unwrap().text.text, text); + assert_eq!(rows[1][0], text); + } + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a5..2db8bd7aa 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -62,7 +62,7 @@ score below). | HEIC | `.heic` | ✓ | ✗ | | <1% | | Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | | RTF | `.rtf` | ✓ | ✓ | | <1% | -| Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | no sheet/range options; `.xls` ✗ | <1% | +| Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | cached numbers keep their precision and formula strings stay text; no sheet/range options; `.xls` ✗ | <1% | | Plain text, Markdown | `.txt`, `.md` | ✓ (txt) | ✓ | | <1% | | InDesign Tagged Text | `.txt` | ✓ | ✓ import and export | | <1% | | CSV / TSV (Data Merge) | | ✓ | ✓ (+ Excel) | | <1% | @@ -107,4 +107,5 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | XLSX table placement and data merge preserve small numeric values and cached formula strings; synthetic regressions in `textimport::xlsx::tests`. Scores unchanged. | | 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code | From 5e22a3dbeef4d634a91955dab21e1781e173db43 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Mon, 12 Oct 2026 19:50:14 -0700 Subject: [PATCH 2/2] fix(import): show XLSX numbers as Excel's General format does Round cached doubles to 15 significant digits, so =0.1+0.2 reads 0.3, and write very large or small values with an exponent (1E+300). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/textimport/src/xlsx.rs | 49 +++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index 99d4db0a1..d07956998 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -51,16 +51,34 @@ fn value(c: &El, shared: &[String]) -> String { } _ => { let raw = v(); - // Whole numbers without the trailing ".0" Excel never shows. match raw.trim().parse::() { - Ok(f) if f.fract() == 0.0 && f.abs() < 1e15 => format!("{}", f as i64), - Ok(f) => f.to_string(), - Err(_) => raw, + Ok(f) if f.is_finite() => general(f), + _ => raw, } } } } +/// A number as Excel's General format shows it: at most 15 significant digits (Excel stores +/// doubles with 17, so `=0.1+0.2` is cached as 0.30000000000000004 and shown as 0.3), whole +/// numbers without ".0", and an exponent for very large or very small values. +fn general(f: f64) -> String { + let r = format!("{f:.14e}").parse::().unwrap_or(f); + let a = r.abs(); + if r == 0.0 { + "0".into() + } else if (1e-9..1e15).contains(&a) { + // Shortest text that reads back as the rounded value; whole numbers drop the ".0". + r.to_string() + } else { + let s = format!("{r:E}"); + match s.split_once('E') { + Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"), + _ => s, + } + } +} + /// One worksheet as a grid of cached cell text, empty cells kept, for data merge. /// The first used row is the header. Later rows that are entirely empty are left out. /// Cells past the header are kept so the caller can warn. Over 200 columns or 100,000 @@ -345,7 +363,7 @@ mod tests { #[test] fn numeric_values_keep_their_precision_in_tables_and_records() { - for text in ["1e-12", "-0.0000000000123", "1.23456789012345", "1e300", "12.0"] { + for text in ["1e-12", "-0.0000000000123", "1.23456789012345", "1e300", "12.0", "-2.5e-300"] { let book = one_value(&format!(r#"{text}"#)); let placed = crate::import("values.xlsx", &book).unwrap(); let table = placed.story.tables.values().next().unwrap(); @@ -356,6 +374,27 @@ mod tests { } } + #[test] + fn numeric_values_show_as_excel_general_format() { + for (text, shown) in [ + ("0.30000000000000004", "0.3"), + ("2.0000000000000004", "2"), + ("12.0", "12"), + ("-0.5", "-0.5"), + ("0", "0"), + ("123456789012345", "123456789012345"), + ("1e15", "1E+15"), + ("1e300", "1E+300"), + ("1e-12", "1E-12"), + ("-1.5e-10", "-1.5E-10"), + ("inf", "inf"), + ] { + let book = one_value(&format!(r#"{text}"#)); + let rows = crate::xlsx_records(&book, None).unwrap(); + assert_eq!(rows[1][0], shown, "cell {text}"); + } + } + #[test] fn cached_string_values_are_not_reformatted_as_numbers() { for text in ["00123", "1e3", "12.0", " 42 "] {