RTF import: keep section breaks and their page settings (#1378) (#1495)

* #1378: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1378: pasted sections link their headers; section orientation wins over the document's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1378: Text from File clears inserted sections' headers too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-14 13:44:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent 4cf13cd178
commit 1f2b2287b6
9 changed files with 437 additions and 35 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: progress log: RTF import keeps sections, #1378; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -164,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) |
| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height |
| 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again |
| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's |
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
@@ -325,6 +326,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) |
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) |
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
+2 -1
View File
@@ -115,7 +115,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| {
let path = p::req_str(v, "path")?;
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
let frag = doc.body_fragment();
let mut frag = doc.body_fragment();
super::paste::unlink_sections(&mut frag.blocks);
let at = delete_selection(s)?;
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
+16
View File
@@ -304,6 +304,21 @@ pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Para
}
}
/// Section breaks brought in from another document keep their page, but their header and
/// footer ids are that document's: clear them (and the different first page that went with
/// them) so the sections link to the previous section's headers and footers, as in Word.
pub(crate) fn unlink_sections(blocks: &mut [Block]) {
for b in blocks {
each_para(b, 0, &mut |p| {
if let Some(sec) = p.section.as_deref_mut() {
sec.headers = Default::default();
sec.footers = Default::default();
sec.title_page = false;
}
});
}
}
/// Turn an imported document into a fragment of the session's document: bring over the media,
/// lists and styles it refers to (renumbered so they can't collide with ours).
fn adopt(s: &mut Session, src: Document) -> Fragment {
@@ -354,6 +369,7 @@ fn adopt(s: &mut Session, src: Document) -> Fragment {
}
});
}
unlink_sections(&mut blocks);
// Text box stories the imported shapes show; pasting gives each a new story of its own.
let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect();
Fragment { blocks, parts }
+58
View File
@@ -911,6 +911,64 @@ fn paste_special_lists_formats_and_pastes_text_without_formatting() {
assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
}
#[test]
fn pasted_rtf_sections_link_their_headers_to_the_previous() {
// #1378: the RTF's own header part ids mean nothing in this document; a pasted section
// break keeps its page but links its headers and footers to the previous section.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "x"}));
let note = s.doc.add_part(wordcraft_doc::PartKind::Footnote, Vec::new());
let _comment = s.doc.add_part(wordcraft_doc::PartKind::Comment, Vec::new());
run(&mut s, "caret.docEnd", json!({}));
let rtf = r"{\rtf1\ansi\sectd\lndscpsxn\titlepg{\header Head\par}{\footer Foot\par}{\headerf First\par} a\par\sect\sectd{\header Two\par} b\par}";
run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": rtf}));
let secs = s.doc.sections();
assert_eq!(secs.len(), 2, "the pasted section break is kept");
assert!(secs[0].1.landscape, "with its page settings");
for (_, sp) in &secs {
for (set, kind) in [(sp.headers, wordcraft_doc::PartKind::Header), (sp.footers, wordcraft_doc::PartKind::Footer)] {
for id in [set.default, set.first, set.even].into_iter().flatten() {
assert_eq!(s.doc.parts.get(&id).map(|p| p.kind), Some(kind), "section refers to part {id} (footnote is {note})");
}
}
}
assert_unlinked(&s);
}
/// Every section break in the body has no headers, footers or first-page setting of its own.
fn assert_unlinked(s: &Session) {
for (i, sp) in s.doc.sections() {
assert_eq!((sp.headers, sp.footers, sp.title_page), Default::default(), "section ending at block {i}");
}
}
#[test]
fn text_from_file_unlinks_inserted_sections_headers() {
// #1378: Insert › Text from File brings the file's section breaks with their pages, but
// the file's header and footer ids are its own: they link to the previous section's.
let dir = std::env::temp_dir().join(format!("wordcraft-text-from-file-sections-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let rtf = r"{\rtf1\ansi\sectd\lndscpsxn\titlepg{\header Head\par}{\footer Foot\par}{\headerf First\par} a\par\sect\sectd{\header Two\par} b\par}";
let rtf_path = dir.join("sections.rtf");
std::fs::write(&rtf_path, rtf).unwrap();
// The same document as DOCX.
let docx_path = dir.join("sections.docx");
let src = crate::io::open_bytes("sections.rtf", rtf.as_bytes()).unwrap();
std::fs::write(&docx_path, crate::io::save_bytes("sections.docx", &src).unwrap()).unwrap();
for path in [&rtf_path, &docx_path] {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "x"}));
s.doc.add_part(wordcraft_doc::PartKind::Footnote, Vec::new());
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.textFromFile", json!({"path": path.to_str().unwrap()}));
let secs = s.doc.sections();
assert!(secs.len() >= 2, "{path:?}: the inserted section break is kept");
assert!(secs[0].1.landscape, "{path:?}: with its page settings");
assert_unlinked(&s);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn paste_special_html_and_rtf_keep_bold() {
let mut s = s();
+67
View File
@@ -0,0 +1,67 @@
//! #1378: an RTF file with several sections converts to DOCX with one `w:sectPr` per section.
//! The RTF is original and synthetic, laid out the way word processors write sections: document
//! page defaults, then `\sectd` + section words + the section's header/footer groups, a `\sect`
//! after the last `\par` (or after a table), and a `\sectd` inside a footer's field result.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Read;
use wordcraft_engine::io;
const RTF: &str = r"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Times New Roman;}}
\paperw11906\paperh16838\margl1134\margr1134\margt1417\margb1134
\sectd\headery708\footery708\colsx708
{\headerr \pard\plain Part one - upright\par}
{\footerr \pard\plain Pg. {\field{\*\fldinst PAGE}{\fldrslt 1}}\sectd\colsx708 {\par}}
\pard\plain Annual summary\par
\pard\plain Opening text of the first part.\par \sect }
\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\pgnrestart\headery708\footery708\colsx708
{\headerr \pard\plain Part two - wide\par}
{\footerr \pard\plain Pg. {\field{\*\fldinst PAGE}{\fldrslt 1}}\sectd\lndscpsxn\pgwsxn16838\pghsxn11906 {\par}}
\pard\plain Volumes table\par
\trowd\cellx3000\cellx6000\pard\intbl A\cell B\cell\row
\pard {\sect }
\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\sbknone\pgnrestart\headery708\footery708\cols2\colsx708\linebetcol
\pard\plain Column text one.\par
\pard\plain Column text two.\par \sect }
\sectd\pgnrestart\headery708\footery708\colsx708
\pard\plain Closing words\par
\pard\plain Last line.\par}";
fn document_xml(docx: &[u8]) -> String {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut xml = String::new();
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
}
#[test]
fn rtf_sections_convert_to_one_docx_section_each() {
let doc = io::open_bytes("sections.rtf", RTF.as_bytes()).unwrap();
assert_eq!(doc.sections().len(), 4);
let xml = document_xml(&io::save_bytes("sections.docx", &doc).unwrap());
// One sectPr per section, in order.
let secs: Vec<&str> = xml.split("<w:sectPr").skip(1).map(|s| s.split("</w:sectPr>").next().unwrap_or("")).collect();
assert_eq!(secs.len(), 4, "{xml}");
let orient: Vec<bool> = secs.iter().map(|s| s.contains(r#"w:orient="landscape""#)).collect();
assert_eq!(orient, [false, true, true, false]);
// A4 both ways.
assert!(secs[0].contains(r#"w:w="11906""#) && secs[0].contains(r#"w:h="16838""#), "{}", secs[0]);
assert!(secs[1].contains(r#"w:w="16838""#) && secs[1].contains(r#"w:h="11906""#), "{}", secs[1]);
// Section 3: continuous, two columns with a line between.
assert!(secs[2].contains(r#"<w:type w:val="continuous""#), "{}", secs[2]);
assert!(secs[2].contains(r#"w:num="2""#), "{}", secs[2]);
assert!(!secs[0].contains(r#"w:num="2""#) && !secs[3].contains(r#"w:num="2""#));
// Numbering restarts at 1 in sections 2-4, not in 1.
assert!(!secs[0].contains(r#"w:start="#), "{}", secs[0]);
for s in &secs[1..] {
assert!(s.contains("<w:pgNumType") && s.contains(r#"w:start="1""#), "{s}");
}
// Sections 1 and 2 have their own header and footer; 3 and 4 link to the previous.
for s in &secs[..2] {
assert!(s.contains("<w:headerReference") && s.contains("<w:footerReference"), "{s}");
}
for s in &secs[2..] {
assert!(!s.contains("<w:headerReference") && !s.contains("<w:footerReference"), "{s}");
}
}
+5
View File
@@ -112,6 +112,8 @@ pub struct Para {
pub spacing: Spacing,
/// Borders the source puts on the paragraph itself (RTF `\brdrb`…).
pub borders: Option<wordcraft_doc::props::Borders>,
/// The section this paragraph ends (RTF `\sect`), as the document's last paragraph of it.
pub section: Option<Box<wordcraft_doc::SectionProps>>,
}
impl Para {
@@ -419,6 +421,7 @@ impl Builder<'_> {
if p.page_break {
out.props.page_break_before = Some(true);
}
out.section.clone_from(&p.section);
// Inlines are appended in order, so build the runs directly: inserting each one through
// `Paragraph::insert_text` rescans every earlier run and is quadratic in their number.
for i in &p.inlines {
@@ -741,6 +744,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
inlines: Vec::new(),
spacing: Spacing::default(),
borders: None,
section: None,
};
let in_code = kind == Kind::Code;
let mut k = 0usize;
@@ -761,6 +765,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
inlines: Vec::new(),
spacing: Spacing::default(),
borders: None,
section: None,
};
let prev = std::mem::replace(&mut out, next);
if prev.inlines.is_empty() {
+280 -29
View File
@@ -25,6 +25,8 @@ use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo
const MAX_GROUPS: usize = 512;
/// Largest picture we decode (bytes).
const MAX_PICT: usize = 64 << 20;
/// Most sections we read (`\sect`); later breaks end paragraphs only.
const MAX_SECTIONS: usize = 10_000;
// ---------------------------------------------------------------------------------------------
// Export
@@ -639,6 +641,56 @@ impl PageGeom {
}
}
/// Section formatting (`\sectd` resets it): the section's page words (`\pgwsxn`…, over the
/// document's `\paperw`…), its break, columns, page numbering and title page.
#[derive(Clone, Debug, Default)]
struct SecFmt {
page: PageGeom,
start: wordcraft_doc::section::SectionStart,
cols: Option<i32>,
colsx: Option<i32>,
linebetcol: bool,
pgn_restart: bool,
pgn_start: Option<i32>,
pgn_fmt: wordcraft_doc::section::NumFormat,
title_page: bool,
}
impl SecFmt {
/// The section's properties over the document's page (`doc`); headers and footers unset.
fn props(&self, doc: &PageGeom) -> wordcraft_doc::SectionProps {
let p = &self.page;
let page = PageGeom {
w: p.w.or(doc.w),
h: p.h.or(doc.h),
left: p.left.or(doc.left),
right: p.right.or(doc.right),
top: p.top.or(doc.top),
bottom: p.bottom.or(doc.bottom),
header: p.header.or(doc.header),
footer: p.footer.or(doc.footer),
// A section that gives its own page size or orientation has its own orientation;
// the document's `\landscape` only applies to sections that give neither.
landscape: if p.w.is_some() || p.h.is_some() || p.landscape { p.landscape } else { doc.landscape },
};
let mut s = page.section();
s.start = self.start;
if let Some(n) = self.cols {
s.columns.count = n.clamp(1, 45) as u32;
}
if let Some(x) = self.colsx {
s.columns.space = (x as f32 / 20.0).clamp(0.0, 1584.0);
}
s.columns.separator = self.linebetcol;
if self.pgn_restart {
s.page_num_start = Some(self.pgn_start.unwrap_or(1).clamp(0, 32_767) as u32);
}
s.page_num_format = self.pgn_fmt;
s.title_page = self.title_page;
s
}
}
#[derive(Default)]
struct Reader {
st: St,
@@ -654,14 +706,17 @@ struct Reader {
ansicpg: u16,
/// The default font (`\deff`), whose charset applies before any `\f`.
deff: i32,
/// The document's page (`\paperw`, `\margl`…) and the current section's formatting.
page: PageGeom,
sec: SecFmt,
/// The headers and footers of each section ended so far (`\sect`); `hf_parts` holds the
/// current section's.
section_parts: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
/// The header or footer being read: the group depth it started at, which one, and the
/// body's output, put back when the group closes.
hf: Option<(usize, HeaderFooter, Box<Output>)>,
/// Headers and footers read, in order.
/// The current section's headers and footers, in order.
hf_parts: Vec<(HeaderFooter, Vec<FBlock>)>,
/// `\titlepg`: the first page has its own header and footer.
title_page: bool,
/// The cell border side being described (`\clbrdrt`, `l`, `b`, `r`): its line style, width
/// and colour follow.
cell_side: Option<char>,
@@ -762,13 +817,75 @@ impl Reader {
}
self.close_table();
let part = self.swap_output(*body);
// A document names each one once; a repeat (another section's) replaces it.
// A section names each one once; a repeat replaces it.
self.hf_parts.retain(|(w, _)| *w != which);
if self.hf_parts.len() < 16 {
self.hf_parts.push((which, part.body));
}
}
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
/// headers and footers read since the last break. Inside a header, footer or table, and past
/// [`MAX_SECTIONS`], it only ends the paragraph.
fn end_section(&mut self) {
self.end_para(false);
if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
return;
}
let props = self.sec.props(&self.page);
if let Some(FBlock::Para(p)) = self.body.last_mut() {
p.section = Some(Box::new(props));
self.section_parts.push(std::mem::take(&mut self.hf_parts));
}
}
/// A section formatting word: applied to the current section, unless it's inside a header
/// or footer (whose `\sectd` isn't the body's). False when `w` isn't one.
fn section_word(&mut self, w: &str, p: Option<i32>) -> bool {
use wordcraft_doc::section::{NumFormat, SectionStart};
let on = p != Some(0);
let pv = p.unwrap_or(0);
// Inside a header or footer the word is read into a scratch copy and dropped.
let mut scratch;
let sec = if self.hf.is_some() {
scratch = self.sec.clone();
&mut scratch
} else {
&mut self.sec
};
match w {
"sectd" => *sec = SecFmt::default(),
"titlepg" => sec.title_page = on,
"pgwsxn" => sec.page.w = Some(pv),
"pghsxn" => sec.page.h = Some(pv),
"marglsxn" => sec.page.left = Some(pv),
"margrsxn" => sec.page.right = Some(pv),
"margtsxn" => sec.page.top = Some(pv),
"margbsxn" => sec.page.bottom = Some(pv),
"headery" => sec.page.header = Some(pv),
"footery" => sec.page.footer = Some(pv),
"lndscpsxn" => sec.page.landscape = on,
"sbknone" => sec.start = SectionStart::Continuous,
"sbkcol" => sec.start = SectionStart::NextColumn,
"sbkpage" => sec.start = SectionStart::NextPage,
"sbkeven" => sec.start = SectionStart::EvenPage,
"sbkodd" => sec.start = SectionStart::OddPage,
"cols" => sec.cols = Some(pv),
"colsx" => sec.colsx = Some(pv),
"linebetcol" => sec.linebetcol = on,
"pgnrestart" => sec.pgn_restart = true,
"pgncont" => sec.pgn_restart = false,
"pgnstarts" => sec.pgn_start = Some(pv),
"pgndec" => sec.pgn_fmt = NumFormat::Decimal,
"pgnucrm" => sec.pgn_fmt = NumFormat::UpperRoman,
"pgnlcrm" => sec.pgn_fmt = NumFormat::LowerRoman,
"pgnucltr" => sec.pgn_fmt = NumFormat::UpperLetter,
"pgnlcltr" => sec.pgn_fmt = NumFormat::LowerLetter,
_ => return false,
}
true
}
fn open(&mut self) {
self.skip_chars = 0;
if self.stack.len() >= MAX_GROUPS {
@@ -1316,7 +1433,7 @@ impl Reader {
}
match w {
"par" => self.end_para(false),
"sect" => self.end_para(false),
"sect" => self.end_section(),
"cell" => self.end_cell(),
"nestcell" => self.end_para(true),
"row" => self.end_row(),
@@ -1474,16 +1591,14 @@ impl Reader {
b.color = color;
}
}
"titlepg" => self.title_page = on,
"paperw" | "pgwsxn" => self.page.w = Some(pv),
"paperh" | "pghsxn" => self.page.h = Some(pv),
"margl" | "marglsxn" => self.page.left = Some(pv),
"margr" | "margrsxn" => self.page.right = Some(pv),
"margt" | "margtsxn" => self.page.top = Some(pv),
"margb" | "margbsxn" => self.page.bottom = Some(pv),
"headery" => self.page.header = Some(pv),
"footery" => self.page.footer = Some(pv),
"landscape" | "lndscpsxn" => self.page.landscape = on,
"paperw" => self.page.w = Some(pv),
"paperh" => self.page.h = Some(pv),
"margl" => self.page.left = Some(pv),
"margr" => self.page.right = Some(pv),
"margt" => self.page.top = Some(pv),
"margb" => self.page.bottom = Some(pv),
"landscape" => self.page.landscape = on,
_ if self.section_word(w, p) => {}
"cellx" => {
self.cell_side = None;
let mut d = std::mem::take(&mut self.def_cur);
@@ -1550,9 +1665,11 @@ fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), Str
struct Parsed {
flow: Flow,
page: PageGeom,
/// The last section's properties (the others are on the paragraphs that end them).
last_section: wordcraft_doc::SectionProps,
default_font: Option<String>,
headers_footers: Vec<(HeaderFooter, Vec<FBlock>)>,
title_page: bool,
/// Each section's headers and footers, in order, the last section's last.
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
}
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
@@ -1722,11 +1839,22 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
r.meta.created = format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59));
}
let mut blocks = std::mem::take(&mut r.body);
// A file that ends with `\sect` ends its last section there: no empty one follows, and
// header or footer groups after that `\sect` (a section without text) are dropped.
let mut last_section = r.sec.props(&r.page);
let mut headers_footers = std::mem::take(&mut r.section_parts);
if let Some(FBlock::Para(p)) = blocks.last_mut()
&& let Some(s) = p.section.take()
{
last_section = *s;
} else {
headers_footers.push(std::mem::take(&mut r.hf_parts));
}
if blocks.is_empty() {
blocks.push(FBlock::Para(Para::default()));
}
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned();
Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, default_font, headers_footers: r.hf_parts, title_page: r.title_page })
Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers })
}
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
@@ -1768,19 +1896,38 @@ fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f32> {
/// Parse RTF bytes into a document.
pub fn import(bytes: &[u8]) -> Result<Document, String> {
let Parsed { flow, page, default_font, headers_footers, title_page } = parse_full(bytes)?;
let mut doc = model::to_doc_in(&flow, page.section());
for (which, blocks) in &headers_footers {
let blocks = model::part_blocks(&mut doc, blocks);
let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks);
let set = if which.footer { &mut doc.last_section.footers } else { &mut doc.last_section.headers };
match which.pages {
HfPages::Default => set.default = Some(id),
HfPages::First => set.first = Some(id),
HfPages::Even => set.even = Some(id),
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
let mut doc = model::to_doc_in(&flow, last_section);
// Each section's own headers and footers; those it leaves out stay linked to the previous.
let mut sets = Vec::with_capacity(headers_footers.len());
for parts in &headers_footers {
let (mut headers, mut footers) = (wordcraft_doc::section::HeaderSet::default(), wordcraft_doc::section::HeaderSet::default());
for (which, blocks) in parts {
let blocks = model::part_blocks(&mut doc, blocks);
let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks);
let set = if which.footer { &mut footers } else { &mut headers };
match which.pages {
HfPages::Default => set.default = Some(id),
HfPages::First => set.first = Some(id),
HfPages::Even => set.even = Some(id),
}
}
sets.push((headers, footers));
}
let mut sets = sets.into_iter();
for b in &mut doc.body {
if let wordcraft_doc::Block::Para(p) = &**b
&& p.section.is_some()
&& let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b)
&& let Some(s) = p.section.as_deref_mut()
&& let Some((h, f)) = sets.next()
{
(s.headers, s.footers) = (h, f);
}
}
doc.last_section.title_page = title_page;
if let Some((h, f)) = sets.next() {
(doc.last_section.headers, doc.last_section.footers) = (h, f);
}
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
// wants beyond that it states on each paragraph.
let dp = &mut doc.styles.default_para;
@@ -2081,6 +2228,110 @@ mod tests {
assert_eq!(texts(&f).last().map(String::as_str), Some("Normal:Café"));
}
#[test]
fn sections_keep_their_page_columns_numbering_and_headers() {
use wordcraft_doc::section::{NumFormat, SectionStart};
// A4 portrait → landscape restarting at 1 with its own header → continuous two columns
// → portrait. A `\sectd` inside a footer group is the footer's, not the body's.
let rtf = br"{\rtf1\ansi\paperw11906\paperh16838\margl1134\margr1134
\sectd\headery708\footery708
{\header \pard One head\par}
{\footer \pard Page {\field{\*\fldinst PAGE}{\fldrslt 1}}\sectd\par}
\pard First\par\sect
\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\headery708\pgnrestart\pgnstarts1\pgnlcrm\titlepg
{\header \pard Two head\par}
{\headerf \pard Two first\par}
{\footer \pard Two foot\sectd\lndscpsxn\par}
\pard Second\par
\trowd\cellx2000\pard\intbl cell\cell\row
\pard{\sect}
\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\headery708\sbknone\pgnrestart\cols2\colsx567\linebetcol
\pard Third\sect
\sectd\headery708\sbkodd
\pard Fourth\par}";
let d = import(rtf).unwrap();
let secs = d.sections();
assert_eq!(secs.len(), 4, "{secs:?}");
let s: Vec<&wordcraft_doc::SectionProps> = secs.iter().map(|(_, s)| *s).collect();
// Page size and orientation; the document's margins apply to every section.
for (i, land) in [(0, false), (1, true), (2, true), (3, false)] {
assert_eq!(s[i].landscape, land, "section {i}");
let (w, h) = if land { (841.9, 595.3) } else { (595.3, 841.9) };
assert!((s[i].page_w - w).abs() < 0.1 && (s[i].page_h - h).abs() < 0.1, "section {i}: {} x {}", s[i].page_w, s[i].page_h);
assert!((s[i].margin_left - 56.7).abs() < 0.1, "section {i}");
assert!((s[i].header - 35.4).abs() < 0.1, "section {i}");
}
// Break types.
assert_eq!(
s.iter().map(|s| s.start).collect::<Vec<_>>(),
[SectionStart::NextPage, SectionStart::NextPage, SectionStart::Continuous, SectionStart::OddPage]
);
// Columns.
assert_eq!(s[2].columns.count, 2);
assert!((s[2].columns.space - 28.35).abs() < 0.01 && s[2].columns.separator);
assert_eq!(s[0].columns.count, 1);
assert_eq!(s[3].columns.count, 1);
// Page numbering: restart at 1 (lower roman) in 2 and 3; section 4 carries on.
assert_eq!(s.iter().map(|s| s.page_num_start).collect::<Vec<_>>(), [None, Some(1), Some(1), None]);
assert_eq!(s[1].page_num_format, NumFormat::LowerRoman);
assert_eq!(s[0].page_num_format, NumFormat::Decimal);
assert!(!s[0].title_page && s[1].title_page && !s[3].title_page);
// Headers and footers: sections 1 and 2 have their own; 3 and 4 inherit (none set).
let text = |id: Option<u32>| {
let p = id.and_then(|id| d.parts.get(&id)).unwrap();
p.blocks
.iter()
.filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None })
.collect::<Vec<_>>()
.join("|")
};
assert_eq!(text(s[0].headers.default), "One head");
assert!(text(s[0].footers.default).starts_with("Page"));
assert_eq!(text(s[1].headers.default), "Two head");
assert_eq!(text(s[1].headers.first), "Two first");
assert_eq!(text(s[1].footers.default), "Two foot");
assert!(s[0].headers.first.is_none());
for i in [2, 3] {
assert_eq!((s[i].headers, s[i].footers), Default::default(), "section {i}");
}
// Each section ends on its own paragraph, after the text it holds.
let body_text = |i: usize| match d.body.get(i).map(|b| &**b) {
Some(wordcraft_doc::Block::Para(p)) => p.text.clone(),
_ => "<table>".into(),
};
assert_eq!(body_text(secs[0].0 - 1), "First");
assert_eq!(body_text(secs[1].0 - 1), "<table>");
assert_eq!(body_text(secs[2].0), "Third");
assert_eq!(body_text(secs[3].0), "Fourth");
// A landscape document default doesn't turn a section that gives its own portrait page.
let d = import(br"{\rtf1\ansi\paperw16838\paperh11906\landscape\sectd a\sect\sectd\pgwsxn11906\pghsxn16838 b\par}").unwrap();
let secs = d.sections();
assert_eq!(secs.len(), 2);
assert!(secs[0].1.landscape && secs[0].1.page_w > secs[0].1.page_h, "{:?}", secs[0].1);
assert!(!secs[1].1.landscape && secs[1].1.page_w < secs[1].1.page_h, "{:?}", secs[1].1);
}
#[test]
fn sections_are_capped_and_a_trailing_sect_adds_no_empty_section() {
// A file that ends with `\sect` has no empty section after it.
let d = import(br"{\rtf1\ansi\sectd\sbknone a\sect\sectd\lndscpsxn b\sect}").unwrap();
let secs = d.sections();
assert_eq!(secs.len(), 2, "{secs:?}");
assert!(secs[1].1.landscape && !secs[0].1.landscape);
// Hostile: a flood of section breaks with headers is capped and never panics.
let mut rtf = b"{\\rtf1\\ansi".to_vec();
for _ in 0..(MAX_SECTIONS + 50) {
rtf.extend_from_slice(b"\\sectd\\cols99999\\colsx-5\\pgnrestart\\pgnstarts-7{\\header h\\par}{\\footerl f\\par}x\\sect");
}
rtf.push(b'}');
let d = import(&rtf).unwrap();
let secs = d.sections();
assert_eq!(secs.len(), MAX_SECTIONS);
// Each section keeps its own header and footer; the ones past the cap replace the last's.
assert!(d.parts.len() <= 2 * MAX_SECTIONS, "{}", d.parts.len());
assert!(secs.iter().all(|(_, s)| s.columns.count <= 45 && s.columns.space >= 0.0 && s.page_num_start == Some(0)));
}
#[test]
fn rejects_non_rtf() {
assert!(parse(b"hello").is_err());
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: RTF import keeps sections, #1378; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -25,7 +25,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, revisions, section export, RTL |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL |
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) |
@@ -170,6 +170,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing |
| 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `<math>` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) |
| 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) |
| 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) |
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
| 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text |
+3 -2
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
| 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
| 19 | **RTF and ODT depth**: notes, comments, revisions, sections (RTF import done, #1378; RTF export and ODT left), RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
| 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) |
@@ -84,6 +84,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word |
| 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages |
| 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain |
| 2026-10-14 | trivial | RTF import keeps sections (#1378): gap 19 narrowed to RTF section export and ODT sections |
| 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps |
| 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain |
| 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps |