diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0909021951..0849f8fa65 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8521,6 +8521,119 @@ 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. 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(), + "<< /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()), + ]; + 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() { + 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"; + 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" })); + 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."]); +} + +/// 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 new file mode 100644 index 0000000000..4349dd558e --- /dev/null +++ b/crates/export/src/flow.rs @@ -0,0 +1,231 @@ +//! 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. +//! +//! 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::{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). +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; + +/// 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. +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(['.', '!', '?', ':', ';', '…', '。', '!', '?']) +} + +/// 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 +/// 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"). 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(' '); + } + 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 2b96343ebc..a4efa9fc00 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -145,19 +145,31 @@ 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() } /// 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)| { @@ -166,14 +178,20 @@ 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])) .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 @@ -241,6 +259,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. @@ -355,8 +379,16 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec], edges: &[f64 pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec, Vec) { let mut budget = RULE_BUDGET; 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 { @@ -392,7 +424,7 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec
, Vec Vec> { } let (tables, consumed) = tables(&p.blocks, &p.rules); // 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 5ae1700115..885d415fc2 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. @@ -21,6 +21,7 @@ mod as_html; mod as_markdown; mod as_rtf; mod clip; +mod flow; mod layout; pub(crate) use as_docx::hex; @@ -36,10 +37,12 @@ pub use as_markdown::markdown; use as_markdown::{emphasis, paragraph}; pub use as_rtf::rtf; pub use clip::{ClipBuilder, ClipSpan, ClipStyle, ClipText, MAX_CLIP_HTML_BYTES, MAX_CLIP_SPANS, MAX_CLIP_TEXT_BYTES, clip_html, font_family}; +#[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 04cc027ab1..c662f9cb0f 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, &p.rules); @@ -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 e2207c2222..cb520d002e 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -983,3 +983,283 @@ fn a_ruled_table_gets_outer_borders_only_where_the_page_draws_them() { ruled_top.push([0.0, 0.0, 320.0, 0.0]); assert_eq!(tables(&blocks, &ruled_top).0[0].ruled, Some([true, false, false, false]), "only the top line near the table frames it"); } + +/// 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}"); + 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 = + 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); + // 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)]), + ("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}"); + } +} + +/// 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", "а) стаи"] { + 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); + } + } +} + +/// 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 c1e851c150..bf46e16b41 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7203,8 +7203,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::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"] -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). 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: 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 other than a table grid, 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::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 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" @@ -7215,8 +7215,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", "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]] @@ -7267,8 +7267,10 @@ 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", + "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, 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" @@ -7327,8 +7329,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::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"] -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. The page names the PDF's language (``, from a well formed `/Lang`). 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::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_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. The page names the PDF's language (``, from a well formed `/Lang`). 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"