diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 200779baa9..a476e1538b 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -333,11 +333,13 @@ impl crate::Document { let (pages, truncated_pages) = self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); + // Word and HTML name the document's language (`/Lang`), so spelling is checked in it. + let language = self.initial_view().language; let bytes = match format { - OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), + OfficeFormat::Docx => pdfcraft_export::docx_with_language(&pages, &title, language.as_deref()), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), - OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), - OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(), + OfficeFormat::Html => pdfcraft_export::html_with_language(&pages, &title, language.as_deref()).into_bytes(), + OfficeFormat::HtmlSimple => pdfcraft_export::html_simple_with_language(&pages, &title, language.as_deref()).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cf01e9a5a2..dee8ffd727 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6505,6 +6505,35 @@ fn actions_fail_when_hidden_information_could_not_be_removed() { assert!(e.starts_with("Sanitize document:"), "{e}"); } +/// A PDF whose catalog names its language (`/Lang`) exports to Word and HTML in that language, so +/// Word checks the spelling in it, not in English; one that names none exports as before. +#[test] +fn office_exports_take_the_documents_language() { + let pdf = |catalog: &str| { + pdfcraft_testkit::Pdf::new() + .obj(catalog) + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>") + .stream("", b"BT /F1 12 Tf 20 200 Td (Hello) Tj ET") + .build() + }; + let mut s = Session::new(); + let bg = s.open("lang.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R /Lang (bg-BG) >>")), None).unwrap(); + let plain = s.open("plain.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R >>")), None).unwrap(); + let d = s.get(bg).unwrap(); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx == pdfcraft_export::docx_with_language(&d.export_pages(), "lang", Some("bg-BG")), "Word names bg-BG"); + assert!(docx != pdfcraft_export::docx(&d.export_pages(), "lang"), "Word names a language"); + for format in [compare::OfficeFormat::Html, compare::OfficeFormat::HtmlSimple] { + let html = String::from_utf8(d.export_office(format)).unwrap(); + assert!(html.contains(""), "{html}"); + } + let d = s.get(plain).unwrap(); + assert!(d.export_office(compare::OfficeFormat::Docx) == pdfcraft_export::docx(&d.export_pages(), "plain"), "no language, as before"); + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("\n")); + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::HtmlSimple)).unwrap().contains("\n")); +} + #[test] fn exporting_office_files_keeps_images() { let mut s = Session::new(); diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index 6d360d3b34..5cb008a4a0 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,5 +1,5 @@ use super::Zip; -use super::as_html::esc; +use super::as_html::{esc, language_tag}; use super::layout::{Image, Item, Page, Table, body_size, items}; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). @@ -236,9 +236,40 @@ fn word_background(pages: &[Page]) -> Option { (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) } +/// The `w:lang` that makes `tag` the language of a Word document's text. Word keeps one language +/// per script: Chinese, Japanese and Korean are its East Asian text, Arabic, Hebrew, Persian and +/// Urdu its right-to-left text, and any other language its Latin, Greek and Cyrillic text. A +/// script the tag names (`pa-Arab`, `zh-Latn`) decides over its language. +fn word_lang(tag: &str) -> String { + let mut subtags = tag.split('-'); + let primary = subtags.next().unwrap_or_default().to_ascii_lowercase(); + // The script comes straight after the language and any three-letter extended languages; a + // singleton before it starts an extension or private use, which names no script. + let script = subtags + .find(|s| s.len() != 3 || !s.bytes().all(|b| b.is_ascii_alphabetic())) + .filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic())) + .map(str::to_ascii_lowercase); + let attribute = match (script.as_deref(), primary.as_str()) { + (Some("arab" | "hebr" | "syrc" | "thaa"), _) => "w:bidi", + (Some("hans" | "hant" | "hani" | "jpan" | "kore" | "hang" | "hira" | "kana"), _) => "w:eastAsia", + (Some("latn" | "cyrl" | "grek"), _) => "w:val", + (_, "zh" | "ja" | "ko") => "w:eastAsia", + (_, "ar" | "he" | "fa" | "ur") => "w:bidi", + _ => "w:val", + }; + format!("", esc(tag)) +} + /// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; /// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. pub fn docx(pages: &[Page], title: &str) -> Vec { + docx_with_language(pages, title, None) +} + +/// [`docx`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives +/// it): the default language of its text, so Word checks the spelling in that language rather +/// than in its own. A language that is not a well formed tag is left out. +pub fn docx_with_language(pages: &[Page], title: &str, language: Option<&str>) -> Vec { let mut body = String::new(); let mut media: Vec<(String, &Image)> = Vec::new(); // Word needs a paragraph between adjacent tables and after the last one in the body. @@ -346,11 +377,16 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin doc_rels.push_str(""); } doc_rels.push_str(""); - let styles = "\n\ + let defaults = language_tag(language) + .map(|tag| format!("{}", word_lang(tag))) + .unwrap_or_default(); + let styles = format!( + "\n{defaults}\ \ \ \ -"; +" + ); let core = format!( "\n{}", esc(title) diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 3bc6709e69..8c250ca484 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -8,6 +8,41 @@ pub(crate) fn allowed(c: char) -> bool { matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') } +/// The longest language tag the exports write. BCP 47 sets no maximum (35 characters is only +/// the least a protocol must hold), so this bounds only what a PDF's `/Lang` can put in a file. +const LONGEST_TAG: usize = 255; + +/// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary +/// language of two or three letters, then subtags of one to eight letters or digits, joined by +/// hyphens, [`LONGEST_TAG`] characters at most. A singleton (one letter or digit) starts an +/// extension, or with `x` private use, and needs a subtag after it. Anything else names no +/// language. +pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> { + let tag = lang?.trim(); + let mut subtags = tag.split('-'); + let primary = subtags.next()?; + if tag.len() > LONGEST_TAG || !(2..=3).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) { + return None; + } + // In private use every subtag is data, one character long or not. + let (mut private, mut singleton) = (false, false); + for s in subtags { + if !(1..=8).contains(&s.len()) || !s.bytes().all(|b| b.is_ascii_alphanumeric()) { + return None; + } + if s.len() == 1 && !private { + if singleton { + return None; + } + private = s.eq_ignore_ascii_case("x"); + singleton = true; + } else { + singleton = false; + } + } + (!singleton).then_some(tag) +} + pub(crate) fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars().filter(|c| allowed(*c)) { @@ -38,17 +73,31 @@ pub(crate) fn base64(data: &[u8]) -> String { /// One HTML file: headings and paragraphs, tables as real `` elements, images inline, a /// rule between pages. pub fn html(pages: &[Page], title: &str) -> String { - html_with_mode(pages, title, false) + html_with_language(pages, title, None) } /// A simple HTML export with no CSS; page layout and colours are omitted. pub fn html_simple(pages: &[Page], title: &str) -> String { - html_with_mode(pages, title, true) + html_simple_with_language(pages, title, None) } -fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String { +/// [`html`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives +/// it), which the page names (``) for spell checking, speech and hyphenation. A +/// language that is not a well formed tag is left out. +pub fn html_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String { + html_with_mode(pages, title, false, language) +} + +/// [`html_simple`] for a document in `language`, named as [`html_with_language`] names it: a +/// language is not styling, so the simple export keeps it. +pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String { + html_with_mode(pages, title, true, language) +} + +fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String { + let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default(); let mut s = format!( - "\n\n\n\n{}\n{}\n\n\n", + "\n\n\n\n{}\n{}\n\n\n", esc(title), if simple { "" diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1eca8eeca5..5ae1700115 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -23,14 +23,14 @@ mod as_rtf; mod clip; mod layout; -pub use as_docx::docx; pub(crate) use as_docx::hex; #[cfg(test)] use as_docx::{MAX_MEDIA, docx_table}; +pub use as_docx::{docx, docx_with_language}; pub(crate) use as_html::esc; #[cfg(test)] use as_html::{allowed, base64}; -pub use as_html::{html, html_simple}; +pub use as_html::{html, html_simple, html_simple_with_language, html_with_language}; pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index ba38755515..e2207c2222 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -247,6 +247,61 @@ pub(crate) fn part(zip: &[u8], name: &str) -> String { panic!("{name} missing") } +/// The PDF's language (`/Lang`) is the default language of the Word document's text, in the +/// attribute Word keeps for its script, and the HTML page's. No language, or one that is not a +/// well formed tag, leaves both files as they were. +#[test] +fn word_and_html_take_the_documents_language() { + let pages = [page()]; + let styles = |lang| part(&docx_with_language(&pages, "Report", lang), "word/styles.xml"); + let defaults = |lang: &str| format!("{lang}"); + assert!(styles(Some("bg-BG")).contains(&defaults("")), "{}", styles(Some("bg-BG"))); + assert!(styles(Some(" en-GB ")).contains(""), "the tag is trimmed"); + assert!(styles(Some("zh-Hant-TW")).contains(&defaults("")), "East Asian text"); + assert!(styles(Some("ja")).contains(""), "East Asian text"); + assert!(styles(Some("ar-EG")).contains(&defaults("")), "right-to-left text"); + assert!(styles(Some("HE")).contains(""), "right-to-left text"); + // A script subtag decides over the language; private use names no script. + assert!(styles(Some("pa-Arab-PK")).contains(""), "Punjabi in Arabic script"); + assert!(styles(Some("sr-Latn")).contains(""), "Serbian in Latin script"); + assert!(styles(Some("zh-Latn")).contains(""), "Chinese in Latin script"); + assert!(styles(Some("en-x-arab")).contains(""), "private use"); + assert!(styles(Some("az-Latn-x-arab")).contains(""), "private use"); + assert!(html_with_language(&pages, "Report", Some("bg-BG")).starts_with("\n\n")); + // A tag longer than 35 characters is still a tag. + let long = "en-US-u-ca-gregory-nu-latn-co-phonebk"; + assert!(styles(Some(long)).contains(&format!("")), "{long}"); + assert!(html_with_language(&pages, "Report", Some(long)).contains(&format!("")), "{long}"); + let too_long = format!("en{}", "-abcdefgh".repeat(29)); + // Private use takes one-character subtags. + assert!(html_with_language(&pages, "Report", Some("en-x-a-b")).contains("")); + for lang in [ + None, + Some(""), + Some(" "), + Some("bg_BG"), + Some("b"), + Some("bulg-BG"), + Some("bg--BG"), + Some("bg-"), + Some("en-x"), + Some("en-a"), + Some("en-a-x-private"), + Some("en-US-u-ca-x"), + Some("en-US-abcdefghi"), + Some(too_long.as_str()), + Some("en-\"x\""), + Some("1a-BG"), + Some("\u{431}\u{433}-BG"), + ] { + assert!(docx_with_language(&pages, "Report", lang) == docx(&pages, "Report"), "{lang:?}"); + assert_eq!(html_with_language(&pages, "Report", lang), html(&pages, "Report"), "{lang:?}"); + assert_eq!(html_simple_with_language(&pages, "Report", lang), html_simple(&pages, "Report"), "{lang:?}"); + } + // The simple export leaves out styling, not the language. + assert!(html_simple_with_language(&pages, "Report", Some("bg-BG")).starts_with("\n\n")); +} + #[test] fn word_opens_text_with_control_characters_and_long_pages() { // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ca41148247..fc1cadf95f 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"] -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). 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"] +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." [[feature]] id = "create.export-rtf" @@ -7327,8 +7327,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::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." [[feature]] id = "create.export-text"