From ce12a1278876f14792d0fea578773353af6c1c5f Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:36:37 +0200 Subject: [PATCH 1/5] Exports keep a wrapped paragraph as one paragraph Reading splits a line wherever its style changes, and export joined those blocks along one line only (#4158). A sentence with a bold name in one line and an italic name in the next came out of Word, HTML, RTF and PowerPoint as one paragraph per line. A paragraph whose last line reaches the right edge of the page's text now runs on into the line just below it, when that line starts at the same left edge in the same size. Headings, list items, a line that ends a sentence, an indented line and a line after paragraph space keep their break. A slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. The new export and automation tests fail without the change. --- crates/automation/tests/it/automation.rs | 71 ++++++++++++ crates/export/src/flow.rs | 135 +++++++++++++++++++++++ crates/export/src/layout.rs | 28 ++++- crates/export/src/lib.rs | 11 +- crates/export/src/pptx.rs | 3 +- crates/export/src/tests.rs | 135 +++++++++++++++++++++++ parity/acrobat-features.toml | 15 +-- 7 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 crates/export/src/flow.rs diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..4d3aa44f01 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7614,6 +7614,77 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; assert_eq!(paragraphs.len(), 2, "{xml}"); } +/// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2 +/// and so on, each in its built-in encoding. +fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { + let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + objs.extend(fonts.iter().map(|f| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{f} >>"))); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// The text of each paragraph that holds any in `word/document.xml` of the .docx at `path`. +fn word_paragraph_texts(path: &Path) -> Vec { + let zip = std::fs::read(path).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + xml.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect::() + }) + .filter(|text| !text.is_empty()) + .collect() +} + +/// #4158: a sentence that wraps onto the next lines after a bold name and a slanted one reaches +/// Word as one paragraph, though reading splits each of its lines where the style changes. +#[test] +fn exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph() { + let dir = workdir("wrapped-sentence-word"); + let content = "BT /F1 10 Tf 72 700 Td (The parties to this lease are the owner, ) Tj /F2 10 Tf (Harbour Lane Properties) Tj \ +/F1 10 Tf (, of 4 Quay Street, and the) Tj ET \ +BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch Studio) Tj \ +/F1 10 Tf (, who agree as follows to rent the upper) Tj ET \ +BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \ +BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET"; + std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &["Helvetica", "Helvetica-Bold", "Helvetica-Oblique"])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" })); + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs new file mode 100644 index 0000000000..e064772864 --- /dev/null +++ b/crates/export/src/flow.rs @@ -0,0 +1,135 @@ +//! Text that runs on from one line to the next. +//! +//! The reading blocks keep style boundaries (#4158), so a sentence with a bold name or an italic +//! word in it reaches the exporter as several blocks, and `layout::paragraphs` joins only the ones +//! that run on along one line. A sentence that wraps onto the next line after +//! such a run came out as one paragraph per line. [`join_wrapped`] joins a paragraph whose last +//! line reaches the right edge of the page's text with the one that starts just below it at the +//! same left edge, in the same size: the text wrapped there. Headings, list items, a line that +//! ends a sentence and a line with paragraph space after it stay apart. + +use super::layout::Run; + +/// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets +/// Word writes into its PDFs (U+F0B7, U+F0A7). +const BULLETS: [char; 11] = ['•', '◦', '▪', '‣', '⁃', '●', '○', '■', '□', '\u{F0B7}', '\u{F0A7}']; + +/// Dashes and a middle dot: list markers at the start of a line, punctuation inside one. +const DASHES: [char; 4] = ['-', '\u{2013}', '\u{2014}', '·']; + +/// A run taller than this many times its font size is more than one line: a block of several +/// rows ends where its widest row does, not where its last one does. +const ONE_LINE: f64 = 1.6; + +/// How far (as a multiple of the font size) the space between two lines may be from none, and +/// they still follow each other within a paragraph: more is paragraph spacing. +const LINE_GAP: f64 = 0.5; + +/// How close to the right edge of the page's text a full line ends: within this many times the +/// font size, or this fraction of the text's width, whichever is more (a ragged line ends short +/// of the edge by up to a long word). +const FULL_LINE: (f64, f64) = (3.0, 0.12); + +/// How far (points) the first line of a paragraph may start from the left edge of the line above +/// it and still be at the same left edge. +const SAME_LEFT: f64 = 2.0; + +/// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half +/// the shorter one, as `layout::continued_from` has it. +fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool { + a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1]) +} + +/// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed +/// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg", +/// "i.e." and "2026. Year" are text. +pub(crate) fn list_item(text: &str) -> bool { + let t = text.trim_start(); + let Some(first) = t.chars().next() else { return false }; + if BULLETS.contains(&first) || DASHES.contains(&first) || first == '*' { + return true; + } + let digits = t.chars().take_while(char::is_ascii_digit).count(); + // ASCII digits are one byte each and `first` is one whole character, so both cuts fall on + // character boundaries. + let (rest, marks): (&str, &[char]) = if (1..=3).contains(&digits) { + (t.get(digits..).unwrap_or_default(), &['.', ')']) + } else if digits == 0 && first.is_alphabetic() { + (t.get(first.len_utf8()..).unwrap_or_default(), &[')']) + } else { + return false; + }; + let mut after = rest.chars(); + after.next().is_some_and(|c| marks.contains(&c)) && after.next().is_none_or(char::is_whitespace) +} + +/// Whether a line's text ends a sentence (closing quotes and brackets aside). Text set without +/// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left +/// apart from the next rather than joined to it wrongly. +fn ends_sentence(text: &str) -> bool { + text.trim_end().trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']']).ends_with(['.', '!', '?', ':', ';', '…']) +} + +/// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto +/// the next line joined to it. `level` is a paragraph's heading level (0 for body text). +pub(crate) fn join_wrapped<'a>(mut paras: Vec>>, level: impl Fn(&[Run<'a>]) -> u8) -> Vec>> { + // The left and right edges of the page's text: where a full line starts and ends. + let (left, right) = paras + .iter() + .flatten() + .map(|r| r.block.rect) + .filter(|r| r[0].is_finite() && r[2].is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x[0]), r.max(x[2]))); + if right <= left { + return paras; + } + // Top to bottom, then left to right, by each paragraph's first run. + paras.sort_by(|a, b| { + let top_left = |p: &[Run]| p.first().map_or([0.0; 2], |r| [r.block.rect[3], r.block.rect[0]]); + let (a, b) = (top_left(a), top_left(b)); + b[0].total_cmp(&a[0]).then(a[1].total_cmp(&b[1])) + }); + // Each paragraph with its heading level. A joined paragraph is body text: only body text joins. + let mut out: Vec<(Vec>, u8)> = Vec::with_capacity(paras.len()); + for para in paras { + let lvl = level(¶); + match out.last_mut() { + Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => { + // The line break was a word space, unless the line ends in a hyphen; the hyphen + // stays, as it may belong to the word ("well-known"). + if let Some(end) = prev.last_mut() + && !end.text.ends_with(char::is_whitespace) + && !end.text.ends_with('-') + { + end.text.push(' '); + } + prev.extend(para); + } + _ => out.push((para, lvl)), + } + } + out.into_iter().map(|(para, _)| para).collect() +} + +/// Whether the body text paragraph `prev` ends in a full line whose text runs on in `next`, the +/// body text paragraph just below it. `[left, right]` are the edges of the page's text. +fn wraps_into(prev: &[Run], next: &[Run], [left, right]: [f64; 2]) -> bool { + let (Some(last), Some(first)) = (prev.last(), next.first()) else { return false }; + let (a, b) = (last.block, first.block); + let size = a.size; + let alike = size.is_finite() && size > 0.0 && (a.size - b.size).abs() <= 0.5 && a.rotation.abs() <= 0.1 && b.rotation.abs() <= 0.1; + if !alike || a.rect[3] - a.rect[1] > ONE_LINE * size || list_item(&first.text) || ends_sentence(&last.text) { + return false; + } + // The last line: the runs at the end of `prev` that share the last run's line. + let (line_left, line_right) = prev + .iter() + .rev() + .take_while(|r| on_one_line(r.block.rect, a.rect)) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x.block.rect[0]), r.max(x.block.rect[2]))); + let full = line_right >= right - (FULL_LINE.0 * size).max(FULL_LINE.1 * (right - left)); + let same_left = (b.rect[0] - line_left).abs() <= SAME_LEFT; + // Just below: no paragraph space between the two lines. + let gap = a.rect[1] - b.rect[3]; + full && same_left && (-LINE_GAP * size..=LINE_GAP * size).contains(&gap) +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index d1d074ac61..84c59f82bb 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -138,9 +138,20 @@ const INLINE_GAP: f64 = 0.5; /// page laid out so that many end at the same place stays quick. const CONTINUATION_PROBE: usize = 64; +/// How far (as a fraction of the font size) the block before may end past where a block starts +/// and still be the one it continues: a slanted italic's box leans into the next word by about a +/// quarter of its size. +const OVERHANG: f64 = 0.3; + +/// [`OVERHANG`] in points for a block of `size`: at least a point (rounding), at most six. +fn overhang(size: f64) -> f64 { + if size.is_finite() { (OVERHANG * size).clamp(1.0, 6.0) } else { 1.0 } +} + /// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. +/// [`INLINE_GAP`] later, or up to [`overhang`] earlier, as a slanted italic leans into the word +/// after it), so it is the same line of text running on (a link inside a sentence, a +/// superscript), not a cell of its own. fn continuations(blocks: &[Block]) -> Vec { continued_from(blocks).into_iter().map(|from| from.is_some()).collect() } @@ -159,7 +170,7 @@ fn continued_from(blocks: &[Block]) -> Vec> { .get(k..) .unwrap_or_default() .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size)) .take(CONTINUATION_PROBE) .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) @@ -234,6 +245,12 @@ pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec(blocks: &'a [Block], skip: &[bool], body: f64) -> Vec>> { + super::flow::join_wrapped(paragraphs(blocks, skip), |runs| para_level(runs, body)) +} + /// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold /// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run /// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. @@ -450,8 +467,9 @@ pub(crate) fn items(pages: &[Page]) -> Vec> { } let (tables, consumed) = tables(&p.blocks); // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + // A paragraph sits where its first run does: its other runs follow along its line and the + // lines it wraps onto. + let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) .into_iter() .filter_map(|runs| { let first = runs.first()?.block; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..d1c4753aa8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,9 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text, and a block that -//! continues another on its line (an italic word, a link) is a run of that block's paragraph. -//! Word keeps pages with +//! document: headings are the paragraphs set larger than the body text; a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph, and +//! a paragraph that wraps onto the next line runs on there. Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -20,6 +20,7 @@ mod as_docx; mod as_html; mod as_markdown; mod as_rtf; +mod flow; mod layout; pub use as_docx::docx; @@ -34,10 +35,12 @@ pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; pub use as_rtf::rtf; +#[cfg(test)] +use flow::list_item; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; -pub(crate) use layout::{Run, paragraphs, tables}; +pub(crate) use layout::{Run, flowing_paragraphs, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 49765000d3..ff1d1bad7d 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -125,6 +125,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut global_media: Vec<(String, &Image)> = Vec::new(); let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels) + let body = crate::layout::body_size(pages); for p in pages { let (tables, consumed) = crate::tables(&p.blocks); @@ -139,7 +140,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); // The title is a shape of its own, so it joins no paragraph. let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); - for runs in crate::paragraphs(&p.blocks, &skip) { + for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) { let Some(first) = runs.first().map(|r| r.block) else { continue }; items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..11ad393206 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -841,3 +841,138 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}"); assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); } + +/// A line of 10 pt text from `x0` to `x1` whose box starts at `y0`: 12 pt tall, so lines 14 pt +/// apart leave 2 pt between their boxes. +fn line(t: &str, x0: f64, x1: f64, y0: f64) -> Block { + Block { size: 10.0, ..block(t, x0, y0, x1, y0 + 12.0) } +} + +/// The text of each flowing paragraph Word gets for a page of `blocks`. +fn word_texts(blocks: Vec) -> Vec { + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let xml = part(&docx(&[p], "Lines"), "word/document.xml"); + word_paragraphs(&xml).into_iter().map(|runs| runs.into_iter().map(|(t, _)| t).collect()).collect() +} + +/// A sentence over three lines, as the reading blocks give it: split at each style change, a +/// bold name in the first line and a slanted name in the second whose box leans 2.5 pt into the +/// comma after it (#4158). The next paragraph follows after paragraph space. +fn wrapped_sentence() -> Vec { + vec![ + line("The parties to this lease are the owner,", 72.0, 262.0, 700.0), + Block { bold: true, ..line("Harbour Lane Properties", 265.0, 400.0, 700.0) }, + line(", of 4 Quay Street, and the", 400.5, 540.0, 700.0), + line("tenant named in the schedule,", 72.0, 215.0, 686.0), + Block { italic: true, ..line("Blue Finch Studio", 218.0, 312.0, 686.0) }, + line(", who agree as follows to rent the upper", 309.5, 538.0, 686.0), + line("rooms for the whole of the term.", 72.0, 230.0, 672.0), + line("Rent is paid monthly in advance.", 72.0, 230.0, 640.0), + ] +} + +/// #4158: a sentence that wraps onto the next line after a change of style is one paragraph in +/// every format, its lines joined by a word space. +#[test] +fn a_sentence_that_wraps_after_a_style_change_is_one_paragraph() { + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_texts(wrapped_sentence()), [sentence, "Rent is paid monthly in advance."]); + let p = Page { width: 612.0, height: 792.0, blocks: wrapped_sentence(), ..Page::default() }; + let h = html(std::slice::from_ref(&p), "Lease"); + assert_eq!(h.matches("

").count(), 2, "{h}"); + assert!( + h.contains( + "

The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant \ +named in the schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term.

" + ), + "{h}" + ); + let r = rtf(std::slice::from_ref(&p)); + assert_eq!(r.matches("{\\pard").count(), 2, "{r}"); + // On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph. + let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml"); + let slide: Vec = + s.split("").skip(1).map(|p| p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect()).collect(); + assert!(slide.iter().any(|p| p == sentence), "{s}"); +} + +/// A slanted word's box leans into the word after it by about a quarter of its size, so that +/// word still runs on from it; text that starts further back is drawn over the text before it, +/// not after it. +#[test] +fn a_slanted_word_that_leans_into_the_next_stays_in_its_line() { + let leaning = vec![ + line("named in the schedule,", 72.0, 180.0, 700.0), + Block { italic: true, ..line("Blue Finch Studio", 183.0, 276.0, 700.0) }, + line(", who agree", 273.5, 330.0, 700.0), + ]; + assert_eq!(word_texts(leaning), ["named in the schedule, Blue Finch Studio, who agree"]); + let over = vec![line("A label", 72.0, 120.0, 700.0), line("over another", 115.0, 180.0, 700.0)]; + assert_eq!(word_texts(over), ["A label", "over another"]); +} + +/// A line runs on into the next only when it reaches the right edge of the text and the next +/// starts just below at the same left edge, in the same size. A line that ends a sentence or ends +/// short, and a next line that is a heading, a list item, indented or after paragraph space, keep +/// their paragraph break. Each case is a page of its own. +#[test] +fn a_paragraph_breaks_where_its_line_does_not_run_on() { + let full = "The tenant keeps the rooms, the stairs and the shared yard clean and in good repair"; + let next = "throughout the term of the lease"; + let first = |t: &str| line(t, 72.0, 540.0, 700.0); + let second = |t: &str| line(t, 72.0, 240.0, 686.0); + assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]); + assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1); + let heading = |b: Block| Block { bold: true, ..b }; + for (why, blocks) in [ + ("a sentence ends", vec![first(&format!("{full}.")), second(next)]), + ("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]), + ("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]), + ("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]), + ("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]), + ("a larger size", vec![first(full), Block { size: 12.0, ..second(next) }]), + ("a numbered item", vec![first(full), second("2. The tenant pays the rent")]), + ("a lettered item", vec![first(full), second("а) поддръжка на сървърите")]), + ("a bulleted item", vec![first(full), second("• the stairs")]), + ("a heading below", vec![first(full), heading(second("Rent and deposit"))]), + ("a heading above", vec![heading(first("Obligations of the tenant")), second(next)]), + ] { + assert_eq!(word_texts(blocks).len(), 2, "{why}"); + } +} + +#[test] +fn list_items_are_told_from_text() { + for item in ["• item", "\u{F0B7} item", "- item", "\u{2013} item", "* item", "1. Term", "12) Rent", "3.", "a) rooms", "а) стаи"] { + assert!(list_item(item), "{item}"); + } + for text in ["1.5 kg of", "2026. Year", "i.e. this", "т.е. така", "38 rooms", "Rent 12)", "1234) many", ""] { + assert!(!list_item(text), "{text}"); + } +} + +/// Wrapped lines join on hostile boxes and sizes without a panic, and never lose a line's text. +#[test] +fn joining_lines_survives_hostile_boxes() { + let mut blocks = Vec::new(); + for (i, v) in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 1e300].into_iter().enumerate() { + let y = 700.0 - 14.0 * i as f64; + blocks.push(Block { size: v, ..line(&format!("size {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [v, y, 540.0, y + 12.0], ..line(&format!("left {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [72.0, v, v, y + 12.0], ..line(&format!("box {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rotation: v, ..line(&format!("turn {i}"), 72.0, 540.0, y) }); + } + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let outputs = [ + html(std::slice::from_ref(&p), "Hostile"), + rtf(std::slice::from_ref(&p)), + part(&pptx(std::slice::from_ref(&p), "Hostile"), "ppt/slides/slide1.xml"), + word_texts(p.blocks.clone()).concat(), + ]; + for output in outputs { + for b in &p.blocks { + assert!(output.contains(&b.text), "{} was lost", b.text); + } + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..cffbff4176 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7076,8 +7076,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). Not yet: a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7088,8 +7088,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; page breaks. Images are left out." [[feature]] @@ -7140,8 +7140,9 @@ evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", + "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", ] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7200,8 +7201,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" From b98b20633cfab25a675c17fd1414d74aaf7e5248 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:49:19 +0200 Subject: [PATCH 2/5] Exports keep a bulleted list as paragraphs, not a table Word and LibreOffice often draw a list's bullet in a symbol font, a tab stop before the item's text, so each bullet is a block of its own. Table detection read the bullets and the items as two columns, and every export wrote the list as a borderless two-column table. Each bullet is now paired with the text that starts within three times its size after it on its line. The text runs on from its bullet, and bullets stay out of table detection, so Word gets one paragraph per item with the bullet first. The Symbol and Wingdings bullets Word writes (U+F0B7, U+F0A7) count. A dash or a middle dot counts only when nothing starts left of it on its line, so a dash that stands for "none" in a table row stays a cell. The new export and automation tests fail without the change. --- crates/automation/tests/it/automation.rs | 50 +++++++++++-- crates/export/src/flow.rs | 75 +++++++++++++++++++- crates/export/src/layout.rs | 25 +++++-- crates/export/src/tests.rs | 89 ++++++++++++++++++++++++ parity/acrobat-features.toml | 15 ++-- 5 files changed, 237 insertions(+), 17 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 4d3aa44f01..674d8e7666 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7615,8 +7615,9 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; } /// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2 -/// and so on, each in its built-in encoding. -fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { +/// and so on, each in its built-in encoding. A font given `bfchar` pairs (" ") reads +/// its codes through that ToUnicode map, as a symbol font Word embeds does. +fn standard_fonts_pdf(content: &str, fonts: &[(&str, Option<&str>)]) -> Vec { let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect(); let mut objs = vec![ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), @@ -7624,7 +7625,19 @@ fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), ]; - objs.extend(fonts.iter().map(|f| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{f} >>"))); + let mut maps = Vec::new(); + for (font, map) in fonts { + let to_unicode = map.map_or(String::new(), |map| { + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> endcodespacerange \ +1 beginbfchar {map} endbfchar endcmap CMapName currentdict /CMap defineresource pop end end" + ); + maps.push(format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len())); + format!(" /ToUnicode {} 0 R", fonts.len() + 4 + maps.len()) + }); + objs.push(format!("<< /Type /Font /Subtype /Type1 /BaseFont /{font}{to_unicode} >>")); + } + objs.extend(maps); let mut pdf = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -7676,7 +7689,8 @@ BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch /F1 10 Tf (, who agree as follows to rent the upper) Tj ET \ BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \ BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET"; - std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &["Helvetica", "Helvetica-Bold", "Helvetica-Oblique"])).unwrap(); + let fonts = [("Helvetica", None), ("Helvetica-Bold", None), ("Helvetica-Oblique", None)]; + std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &fonts)).unwrap(); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap(); ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" })); @@ -7685,6 +7699,34 @@ schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for th assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]); } +/// A bulleted list drawn as Word draws one, each bullet in the Symbol font (read as U+F0B7, as +/// Word maps it) a tab stop before its item's text, reaches Word as one paragraph per item and +/// not as a two-column table. +#[test] +fn exporting_to_word_keeps_a_bulleted_list_as_paragraphs() { + let dir = workdir("bulleted-list-word"); + let mut content = String::from("BT /F1 10 Tf 72 700 Td (The tenant looks after:) Tj ET\n"); + for (n, item) in ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].iter().enumerate() { + let y = 686 - 14 * n; + content.push_str(&format!("BT /F2 10 Tf 90 {y} Td (\\267) Tj ET BT /F1 10 Tf 108 {y} Td ({item}) Tj ET\n")); + } + content.push_str("BT /F1 10 Tf 72 630 Td (Rent is paid monthly in advance.) Tj ET"); + std::fs::write(dir.join("list.pdf"), standard_fonts_pdf(&content, &[("Helvetica", None), ("Symbol", Some(" "))])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "list.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "list.docx" })); + assert_eq!( + word_paragraph_texts(&dir.join("list.docx")), + [ + "The tenant looks after:", + "\u{F0B7} the stairs and the landing;", + "\u{F0B7} the shared yard and its gate;", + "\u{F0B7} the bins, every week.", + "Rent is paid monthly in advance." + ] + ); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs index e064772864..49ef73e38d 100644 --- a/crates/export/src/flow.rs +++ b/crates/export/src/flow.rs @@ -7,8 +7,13 @@ //! line reaches the right edge of the page's text with the one that starts just below it at the //! same left edge, in the same size: the text wrapped there. Headings, list items, a line that //! ends a sentence and a line with paragraph space after it stay apart. +//! +//! A list item's bullet is often a block of its own: drawn in a symbol font, a tab stop before +//! the item's text. Table detection read such a list as a two-column table, bullets in one +//! column and the items in the other. [`bullet_items`] pairs each bullet with its text, so the +//! text runs on from its bullet and the bullets stay out of table detection. -use super::layout::Run; +use super::layout::{Block, Run}; /// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets /// Word writes into its PDFs (U+F0B7, U+F0A7). @@ -34,12 +39,80 @@ const FULL_LINE: (f64, f64) = (3.0, 0.12); /// it and still be at the same left edge. const SAME_LEFT: f64 = 2.0; +/// How far after its bullet (as a multiple of the bullet's size) a list item's text may start: a +/// tab stop away. +const BULLET_GAP: f64 = 3.0; + +/// How many blocks [`bullet_items`] may look at on one page, so that a page of many bullets stays +/// quick; past it, the bullets left are not taken for bullets. +const BULLET_BUDGET: usize = 1_000_000; + +/// Takes a step from `budget`: whether there was one left. +fn step(budget: &mut usize) -> bool { + budget.checked_sub(1).map(|left| *budget = left).is_some() +} + /// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half /// the shorter one, as `layout::continued_from` has it. fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool { a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1]) } +/// Whether a block is a bullet drawn on its own: one bullet, dash or middle dot, no wider than one +/// and a half times its size. +fn is_bullet(b: &Block) -> bool { + let mut chars = b.text.trim().chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return false }; + (BULLETS.contains(&c) || DASHES.contains(&c)) && b.rect[2] - b.rect[0] <= 1.5 * b.size +} + +/// Whether no other block on `blocks[i]`'s line starts left of it. Each block looked at takes a +/// step from `budget`; when the budget runs out the answer is no. +fn opens_line(i: usize, blocks: &[Block], budget: &mut usize) -> bool { + let Some(a) = blocks.get(i) else { return false }; + for (j, b) in blocks.iter().enumerate() { + if !step(budget) { + return false; + } + if j != i && b.rect[0] < a.rect[0] && on_one_line(a.rect, b.rect) { + return false; + } + } + true +} + +/// The list items of a page whose bullet is a block of its own, as (bullet, text): the text is the +/// block that starts nearest after the bullet on its line, within [`BULLET_GAP`] times its size. +/// A dash or a middle dot is a bullet only when it opens its line; inside a line it is +/// punctuation, or a table cell's "none". The items' texts of a list all start at one tab stop, +/// so the search looks through every block that starts there, within [`BULLET_BUDGET`]. +pub(crate) fn bullet_items(blocks: &[Block]) -> Vec<(usize, usize)> { + let mut by_start: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[0], i)).collect(); + by_start.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut budget = BULLET_BUDGET; + let mut out = Vec::new(); + for (ai, a) in blocks.iter().enumerate().filter(|(_, b)| is_bullet(b)) { + // The text may start a point inside the bullet's box, as a run that continues another can. + let k = by_start.partition_point(|(x, _)| *x < a.rect[2] - 1.0); + let text = by_start + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(x, _)| *x <= a.rect[2] + BULLET_GAP * a.size) + .take_while(|_| step(&mut budget)) + .filter(|(_, bi)| *bi != ai) + .filter_map(|(_, bi)| blocks.get(*bi).map(|b| (*bi, b))) + .find(|(_, b)| on_one_line(a.rect, b.rect) && (a.rotation - b.rotation).abs() <= 0.1 && !is_bullet(b)) + .map(|(bi, _)| bi); + let Some(text) = text else { continue }; + let dash = a.text.trim().chars().next().is_some_and(|c| DASHES.contains(&c)); + if !dash || opens_line(ai, blocks, &mut budget) { + out.push((ai, text)); + } + } + out +} + /// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed /// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg", /// "i.e." and "2026. Year" are text. diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 84c59f82bb..133862b1d9 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -157,11 +157,12 @@ fn continuations(blocks: &[Block]) -> Vec { } /// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. +/// continue, the one that ends nearest to where it starts. A list item's text continues its +/// bullet, a tab stop before it (`flow::bullet_items`). fn continued_from(blocks: &[Block]) -> Vec> { let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks + let mut from: Vec> = blocks .iter() .enumerate() .map(|(bi, b)| { @@ -177,7 +178,13 @@ fn continued_from(blocks: &[Block]) -> Vec> { .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) .map(|(ai, _)| ai) }) - .collect() + .collect(); + for (bullet, text) in super::flow::bullet_items(blocks) { + if let Some(slot @ None) = from.get_mut(text) { + *slot = Some(bullet); + } + } + from } /// A gap between two runs wider than this (as a fraction of the font size) is a word space the @@ -270,8 +277,16 @@ fn is_cell_like(b: &Block) -> bool { /// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { let mut consumed = vec![false; blocks.len()]; + // A list's bullets are not cells: beside their items' text they read as a two-column table. + let mut bullet = vec![false; blocks.len()]; + for (i, _) in super::flow::bullet_items(blocks) { + if let Some(b) = bullet.get_mut(i) { + *b = true; + } + } + let cell_like = |i: usize, b: &Block| is_cell_like(b) && !bullet.get(i).copied().unwrap_or(false); // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, b)| cell_like(*i, b)).map(|(i, b)| (b.rect[0], i)).collect(); xs.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) for (x, i) in xs { @@ -307,7 +322,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); for &bi in &order { let b = &blocks[bi]; - if !is_cell_like(b) { + if !cell_like(bi, b) { continue; } let Some(ci) = column_at(b.rect[0]) else { continue }; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 11ad393206..dc99894d1b 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -976,3 +976,92 @@ fn joining_lines_survives_hostile_boxes() { } } } + +/// A bulleted list as Word and LibreOffice draw one: each bullet a block of its own in a symbol +/// font, a tab stop before its item's text, between a lead-in line and the next paragraph. +fn bulleted_list(bullet: &str) -> Vec { + let mut blocks = vec![line("The tenant looks after:", 72.0, 180.0, 700.0)]; + for (n, (text, end)) in + [("the stairs and the landing;", 230.0), ("the shared yard and its gate;", 240.0), ("the bins, every week.", 200.0)].into_iter().enumerate() + { + let y = 686.0 - 14.0 * n as f64; + blocks.push(line(bullet, 90.0, 94.6, y)); + blocks.push(line(text, 108.0, end, y)); + } + blocks.push(line("Rent is paid monthly in advance.", 72.0, 230.0, 630.0)); + blocks +} + +/// A bulleted list whose bullets are blocks of their own is one paragraph per item, each item's +/// text after its bullet, in every format: not a two-column table of bullets and text. The +/// Symbol and Wingdings bullets Word writes count, and so does a dash that opens its line. +#[test] +fn a_bulleted_list_is_paragraphs_not_a_table() { + for bullet in ["•", "\u{F0B7}", "\u{F0A7}", "\u{2013}"] { + let p = Page { width: 612.0, height: 792.0, blocks: bulleted_list(bullet), ..Page::default() }; + let (found, _) = tables(&p.blocks); + assert!(found.is_empty(), "{bullet}: {found:?}"); + let items = ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].map(|t| format!("{bullet} {t}")); + let mut expected = vec!["The tenant looks after:".to_string()]; + expected.extend(items.iter().cloned()); + expected.push("Rent is paid monthly in advance.".into()); + assert_eq!(word_texts(p.blocks.clone()), expected, "{bullet}"); + let h = html(std::slice::from_ref(&p), "List"); + assert!(!h.contains("
") && items.iter().all(|i| h.contains(&format!("

{i}

"))), "{bullet}: {h}"); + assert!(!rtf(std::slice::from_ref(&p)).contains("\\trowd"), "{bullet}"); + let s = part(&pptx(std::slice::from_ref(&p), "List"), "ppt/slides/slide1.xml"); + assert!(!s.contains(""), "{bullet}: {s}"); + } +} + +/// A dash in a table row stands for "none": it is a cell like the others, not a bullet, so the +/// table keeps all its rows. +#[test] +fn a_dash_in_a_table_row_stays_a_cell() { + let mut blocks = Vec::new(); + for (n, (name, first, second)) in [("Apples", "\u{2013}", "5"), ("Pears", "3", "4"), ("Plums", "2", "6")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line(name, 72.0, 110.0, y)); + blocks.push(line(first, 200.0, 205.0, y)); + blocks.push(line(second, 225.0, 230.0, y)); + } + let (found, consumed) = tables(&blocks); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].rows.len(), 3, "{found:?}"); + assert!(consumed.iter().all(|used| *used), "{consumed:?}"); +} + +/// A bullet is never a table cell, even when its items carry a second column such as an amount: +/// every item keeps its bullet in front of its text. +#[test] +fn a_bullet_is_never_a_table_cell() { + let mut blocks = Vec::new(); + for (n, (item, amount)) in [("apples", "5 kg"), ("pears", "4 kg"), ("plums", "2 kg")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line("•", 90.0, 94.6, y)); + blocks.push(line(item, 108.0, 140.0, y)); + blocks.push(line(amount, 300.0, 325.0, y)); + } + let (found, _) = tables(&blocks); + assert!(found.iter().flat_map(|t| &t.rows).flatten().all(|cell| cell.text != "•"), "{found:?}"); + let paragraphs = word_texts(blocks); + for item in ["• apples", "• pears", "• plums"] { + assert!(paragraphs.iter().any(|p| p.starts_with(item)), "{item}: {paragraphs:?}"); + } +} + +/// Every item of a long list keeps its bullet, though all the items' texts start at one tab stop. +#[test] +fn a_long_bulleted_list_keeps_every_bullet_with_its_item() { + let mut blocks = Vec::new(); + for n in 0..150 { + let y = 2200.0 - 14.0 * f64::from(n); + blocks.push(line("\u{F0B7}", 90.0, 94.6, y)); + blocks.push(line(&format!("item {n};"), 108.0, 160.0, y)); + } + let (found, _) = tables(&blocks); + assert!(found.is_empty(), "{found:?}"); + let paragraphs = word_texts(blocks); + assert_eq!(paragraphs.len(), 150, "{paragraphs:?}"); + assert!(paragraphs.iter().enumerate().all(|(n, p)| *p == format!("\u{F0B7} item {n};")), "{paragraphs:?}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cffbff4176..a349c91fd8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7076,8 +7076,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). Not yet: a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", "crates/export/src/tests.rs::a_bullet_is_never_a_table_cell", "crates/export/src/tests.rs::a_long_bulleted_list_keeps_every_bullet_with_its_item", "crates/export/src/tests.rs::a_dash_in_a_table_row_stays_a_cell", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_bulleted_list_as_paragraphs"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7088,8 +7088,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; page breaks. Images are left out." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; a list whose bullets are blocks of their own is a paragraph per item, not a table, as in Word export; page breaks. Images are left out." [[feature]] @@ -7141,8 +7141,9 @@ evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", + "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", ] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7201,8 +7202,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table, though not yet a
    list), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" From 78b8dfbaefd0c45765df33eea80d0a22e7c65717 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:51:26 +0200 Subject: [PATCH 3/5] Exports join wrapped Chinese and Japanese lines without a space Chinese and Japanese text wraps between characters, so the word space put at each joined line break showed up inside the text. No space is added now when both sides of the break are Chinese or Japanese characters, their punctuation or fullwidth forms; Korean, which spaces its words, still gets one. A line ending in a full-width full stop, exclamation or question mark (also inside closing corner brackets) now ends its paragraph like one ending in a full stop. The new test lines fail without the change. The wrapped sentence test now checks the Markdown export too, which goes through the same joining since it was added. --- crates/export/src/flow.rs | 27 +++++++++++++++++++++++++-- crates/export/src/tests.rs | 14 ++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs index 49ef73e38d..4349dd558e 100644 --- a/crates/export/src/flow.rs +++ b/crates/export/src/flow.rs @@ -140,7 +140,25 @@ pub(crate) fn list_item(text: &str) -> bool { /// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left /// apart from the next rather than joined to it wrongly. fn ends_sentence(text: &str) -> bool { - text.trim_end().trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']']).ends_with(['.', '!', '?', ':', ';', '…']) + text.trim_end() + .trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']', '」', '』']) + .ends_with(['.', '!', '?', ':', ';', '…', '。', '!', '?']) +} + +/// Whether `c` is of the writing that puts no space between words: Chinese and Japanese +/// characters, their punctuation and fullwidth forms. A line that wraps between two of them +/// joins without a word space. +fn unspaced(c: char) -> bool { + matches!( + c, + '\u{3000}'..='\u{30FF}' // CJK punctuation, Hiragana, Katakana + | '\u{31F0}'..='\u{31FF}' // Katakana extension + | '\u{3400}'..='\u{4DBF}' // CJK extension A + | '\u{4E00}'..='\u{9FFF}' // CJK unified ideographs + | '\u{F900}'..='\u{FAFF}' // CJK compatibility ideographs + | '\u{FF01}'..='\u{FF9F}' // fullwidth forms, halfwidth Katakana + | '\u{20000}'..='\u{3FFFF}' // CJK extension B onwards + ) } /// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto @@ -169,10 +187,15 @@ pub(crate) fn join_wrapped<'a>(mut paras: Vec>>, level: impl Fn(&[Ru match out.last_mut() { Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => { // The line break was a word space, unless the line ends in a hyphen; the hyphen - // stays, as it may belong to the word ("well-known"). + // stays, as it may belong to the word ("well-known"). Chinese and Japanese text + // wraps between characters, with no space. + let unspaced_break = |end: &str| { + end.chars().next_back().is_some_and(unspaced) && para.first().and_then(|r| r.text.chars().next()).is_some_and(unspaced) + }; if let Some(end) = prev.last_mut() && !end.text.ends_with(char::is_whitespace) && !end.text.ends_with('-') + && !unspaced_break(&end.text) { end.text.push(' '); } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index dc99894d1b..5af44d3c65 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -890,6 +890,9 @@ named in the schedule, Blue Finch Studio, who agree as follows to rent ); let r = rtf(std::slice::from_ref(&p)); assert_eq!(r.matches("{\\pard").count(), 2, "{r}"); + let md = markdown(std::slice::from_ref(&p)); + assert_eq!(md.trim().split("\n\n").count(), 2, "{md}"); + assert!(md.contains("of 4 Quay Street, and the tenant named in the schedule, "), "{md}"); // On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph. let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml"); let slide: Vec = @@ -924,10 +927,21 @@ fn a_paragraph_breaks_where_its_line_does_not_run_on() { let second = |t: &str| line(t, 72.0, 240.0, 686.0); assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]); assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1); + // Chinese and Japanese wrap between characters: their lines join without a space. Korean + // spaces its words, so its lines join with one. + let zh = ("本合同自双方签字之日起生效,有效期", "为一年,期满前一个月双方协商续签事宜"); + assert_eq!(word_texts(vec![first(zh.0), second(zh.1)]), [format!("{}{}", zh.0, zh.1)]); + assert_eq!( + word_texts(vec![first("この契約は双方が署名した日から"), second("一年間有効とする")]), + ["この契約は双方が署名した日から一年間有効とする"] + ); + assert_eq!(word_texts(vec![first("임차인은 방과 계단을 깨끗하게"), second("유지합니다")]), ["임차인은 방과 계단을 깨끗하게 유지합니다"]); let heading = |b: Block| Block { bold: true, ..b }; for (why, blocks) in [ ("a sentence ends", vec![first(&format!("{full}.")), second(next)]), ("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]), + ("a Chinese sentence ends", vec![first("本合同自双方签字之日起生效。"), second(zh.1)]), + ("a Japanese sentence ends in quotes", vec![first("「この契約は一年間有効ですか?」"), second("双方が署名した日から")]), ("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]), ("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]), ("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]), From ea239829e4ef104256642c0790e425a033f51f43 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 01:18:37 +0700 Subject: [PATCH 4/5] Export tests: a two-column page and a quoted poem keep their lines apart Review asked for the near misses of joining wrapped lines. A short line, a numbered item and a symbol-font bullet were already covered; these add a line beside another column and a poem between paragraphs. --- crates/export/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 2da7e054ac..cb520d002e 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -1098,6 +1098,48 @@ fn a_paragraph_breaks_where_its_line_does_not_run_on() { } } +/// Two columns of text, the right one set half a line lower: a full line in one column never runs +/// on into the line of the other just below it, though the right column's lines reach the page's +/// right edge and the left column's start at its left. +#[test] +fn a_line_never_runs_on_into_the_other_column() { + let left = ["The tenant keeps the rooms and the", "stairs clean and in good repair for", "the whole of the term of the lease"]; + let right = ["The landlord keeps the roof and the", "outer walls sound and dry for the", "whole of the term of the lease too"]; + let mut blocks = Vec::new(); + for (n, (l, r)) in left.iter().zip(right).enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line(l, 72.0, 290.0, y)); + blocks.push(line(r, 322.0, 540.0, y - 7.0)); + } + let paragraphs = word_texts(blocks); + for line in left.iter().chain(&right) { + assert!(paragraphs.iter().any(|p| p.contains(line)), "{line}: {paragraphs:?}"); + } + for p in ¶graphs { + assert!(!(left.iter().any(|l| p.contains(l)) && right.iter().any(|r| p.contains(r))), "{p:?} mixes the columns"); + } +} + +/// A poem quoted between paragraphs keeps its lines: they stop well short of the right edge the +/// prose around it reaches, so none of them is a full line that wraps. +#[test] +fn a_poem_keeps_its_lines() { + let poem = [ + "The kettle sings beside the door", + "the cat is curled upon the chair", + "and rain runs down the window glass", + "while evening settles on the stair", + ]; + let prose = "The tenant may read aloud in the shared garden on summer evenings, as in the verse below"; + let mut blocks = vec![line(prose, 72.0, 540.0, 720.0)]; + for (n, (t, end)) in poem.iter().zip([236.0, 230.0, 248.0, 232.0]).enumerate() { + blocks.push(line(t, 90.0, end, 696.0 - 14.0 * n as f64)); + } + let mut expected = vec![prose]; + expected.extend(poem); + assert_eq!(word_texts(blocks), expected); +} + #[test] fn list_items_are_told_from_text() { for item in ["• item", "\u{F0B7} item", "- item", "\u{2013} item", "* item", "1. Term", "12) Rent", "3.", "a) rooms", "а) стаи"] { From 70fd3ec969ead1972a8421b0c94a57171bded081 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 01:18:57 +0700 Subject: [PATCH 5/5] Parity: Word export names the poem-only page among the joins it gets wrong A page whose only text is short unpunctuated lines takes its longest line for the right edge, so those lines join into one paragraph. --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 139729d4d4..e9ac6f9a0e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7204,7 +7204,7 @@ status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", "crates/export/src/tests.rs::a_bullet_is_never_a_table_cell", "crates/export/src/tests.rs::a_long_bulleted_list_keeps_every_bullet_with_its_item", "crates/export/src/tests.rs::a_dash_in_a_table_row_stays_a_cell", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_bulleted_list_as_paragraphs"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a page whose only text is short lines that end without punctuation, such as a poem (its longest line counts as full, so the lines join), a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf"