From ff0463e299c65a74c0191909cdadc6f16f8092be Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 06:28:07 -0500 Subject: [PATCH] Exports: leave out text the page doesn't show (inherited conditions, deletions, hidden layers) * fix(export): leave out text hidden by inherited conditions or deletion in every exporter * address review: skip hidden frames in page text and hidden article items; first visible paragraph as nav label; read table anchor's own format * address review: skip nonprinting frames and non-printable layers in fixed-layout page text * Apply review fixes - crates/epub/src/html.rs - crates/engine/src/cmd/export/formats.rs * Apply review fixes - crates/epub/src/html.rs - crates/engine/src/cmd/export/formats.rs * address review: fixed-layout page text skips frames in inactive object states * address review: place a threaded story at its first shown frame; skip it only when every frame is hidden --------- Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> --- crates/doc/src/lib.rs | 46 +++++++++++++ crates/engine/src/cmd/export/formats.rs | 21 +++++- crates/engine/src/cmd/export/text_tests.rs | 19 +++++ crates/epub/src/chapters.rs | 5 +- crates/epub/src/html.rs | 51 ++++++++++++-- crates/epub/src/story_writer.rs | 32 +++++++-- crates/epub/src/tests.rs | 69 +++++++++++++++++++ crates/epub/tests/it/grouped_reading_order.rs | 5 +- crates/textimport/src/export.rs | 62 ++++++++++++----- crates/textimport/src/tagged.rs | 4 +- 10 files changed, 281 insertions(+), 33 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d9ee6d3e9..4ebcd665b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -251,6 +251,52 @@ impl Document { pub fn conditions_hide(&self, conds: &[String]) -> bool { !conds.is_empty() && conds.iter().all(|c| self.conditions.iter().any(|x| x.name == *c && !x.visible)) } + + /// Is text with character format `f`, in a paragraph whose base character attributes are + /// `base` (`Styles::resolve_para`), left off the page: hidden by its effective conditions + /// (the paragraph's and character style's included) or marked deleted? Exports leave it out + /// as composition does. + pub fn hides_run(&self, base: &CharProps, f: &CharFormat) -> bool { + let props = self.styles.resolve_char(base, f); + props.change == ChangeMark::Deleted || self.conditions_hide(&props.conditions) + } + + /// The byte ranges of `st` that show, in order: the runs [`Self::hides_run`] hides left out. + /// Paragraphs and runs are walked together, once; take it once per story. + pub fn visible_spans(&self, st: &Story) -> Vec> { + let mut out: Vec> = Vec::new(); + let runs: Vec<(std::ops::Range, &CharFormat)> = st.runs().collect(); + let mut k = 0; + for (pi, pr) in st.para_ranges().iter().enumerate() { + let base = st.paras.get(pi).map(|pf| self.styles.resolve_para(pf).1).unwrap_or_default(); + while runs.get(k).is_some_and(|(rr, _)| rr.end <= pr.start) { + k += 1; + } + for (rr, f) in runs.iter().skip(k).take_while(|(rr, _)| rr.start < pr.end) { + let (a, b) = (rr.start.max(pr.start), rr.end.min(pr.end)); + if a >= b || self.hides_run(&base, f) { + continue; + } + match out.last_mut() { + Some(last) if last.end == a => last.end = b, + _ => out.push(a..b), + } + } + } + out + } + + /// The text of `st` within `range` that lies in `spans` ([`Self::visible_spans`] of `st`). + pub fn visible_text(st: &Story, spans: &[std::ops::Range], range: std::ops::Range) -> String { + let first = spans.partition_point(|s| s.end <= range.start); + let mut out = String::new(); + for s in spans.iter().skip(first).take_while(|s| s.start < range.end) { + if let Some(t) = st.text.get(s.start.max(range.start)..s.end.min(range.end)) { + out.push_str(t); + } + } + out + } } /// Ink Manager settings for output, by swatch name. Output applies them through a diff --git a/crates/engine/src/cmd/export/formats.rs b/crates/engine/src/cmd/export/formats.rs index a25553d69..4474d8ae7 100644 --- a/crates/engine/src/cmd/export/formats.rs +++ b/crates/engine/src/cmd/export/formats.rs @@ -142,7 +142,7 @@ pub(super) fn export_text(s: &mut Session, p: &Value) -> Result { let text = match format { "rtf" => designcraft_textimport::export::rtf(doc, story), "tagged" => designcraft_textimport::tagged::tagged_text(doc, story), - _ => designcraft_textimport::export::plain_text(story), + _ => designcraft_textimport::export::plain_text(doc, story), }; match path { Some(path) => { @@ -159,13 +159,30 @@ fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, let mut out = String::new(); for st in d.stories.values() { let cs = cache.get(d, st.id, None); + let spans = d.visible_spans(st); for ft in &cs.frames { if d.page_of_item(ft.frame) != Some(abs) { continue; } + // Pages render printing-only: a hidden or nonprinting frame or group around it, or a top-level item on a hidden or non-printable layer (members draw on its layer), isn't on the rendered page. + // Inside a multi-state object only the active state is drawn. + if d.find(ft.frame).is_some_and(|loc| { + (1..=loc.path.len()).any(|n| { + d.item_at(&designcraft_doc::ItemLoc { spread: loc.spread, path: loc.path[..n].to_vec() }).is_some_and(|it| { + it.hidden + || it.nonprinting + || n == 1 && d.layers.iter().any(|l| (!l.visible || !l.printable) && l.id == it.layer) + || n < loc.path.len() && !it.states.is_empty() && loc.path[n] != it.active_state + }) + }) + }) { + continue; + } for l in &ft.lines { let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); - let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); + // The line's text as it shows: hidden conditional text and deletions left out. + let t: String = + designcraft_doc::Document::visible_text(st, &spans, r).chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); if !t.trim().is_empty() { out.push_str(t.trim_end_matches('\n')); out.push('\n'); diff --git a/crates/engine/src/cmd/export/text_tests.rs b/crates/engine/src/cmd/export/text_tests.rs index b1b791d3b..48e255ef7 100644 --- a/crates/engine/src/cmd/export/text_tests.rs +++ b/crates/engine/src/cmd/export/text_tests.rs @@ -92,6 +92,25 @@ fn epub_cover_and_fixed_layout() { assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); } +/// Fixed-layout page text leaves out frames in a multi-state object's inactive states. +#[test] +fn fixed_epub_text_skips_inactive_states() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 200, 100], "content": "text", "text": "Active words", "caret": false})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = + s.execute("frame.create", &json!({"rect": [72, 300, 200, 100], "content": "text", "text": "Inactive words", "caret": false})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("states.create", &json!({"ids": [a, b]})).unwrap(); + let r = s.execute("file.exportFixedEpub", &json!({})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let p1 = read(&mut z, "OEBPS/page1.xhtml"); + assert!(p1.contains("Active words") && !p1.contains("Inactive words"), "{p1}"); +} + /// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts /// (and reports those it can't). #[test] diff --git a/crates/epub/src/chapters.rs b/crates/epub/src/chapters.rs index ede96f390..b9b6e6183 100644 --- a/crates/epub/src/chapters.rs +++ b/crates/epub/src/chapters.rs @@ -99,7 +99,8 @@ pub(crate) fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, Strin continue; } let Some(st) = doc.story(*sid) else { continue }; - let first = label(st.text.split('\n').next().unwrap_or("")); + let spans = doc.visible_spans(st); + let first = st.para_ranges().into_iter().map(|r| label(&Document::visible_text(st, &spans, r))).find(|t| !t.is_empty()).unwrap_or_default(); if !first.is_empty() { firsts.push((format!("s{k}"), first, 1)); } @@ -107,7 +108,7 @@ pub(crate) fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, Strin let Some(pf) = st.paras.get(pi) else { continue }; let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc); let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue }; - let text = label(st.text.get(r.clone()).unwrap_or("")); + let text = label(&Document::visible_text(st, &spans, r.clone())); if !text.is_empty() { headings.push((format!("s{k}-p{pi}"), text, level)); } diff --git a/crates/epub/src/html.rs b/crates/epub/src/html.rs index 6442d5045..233c2764e 100644 --- a/crates/epub/src/html.rs +++ b/crates/epub/src/html.rs @@ -14,11 +14,22 @@ pub enum Block { pub fn reading_order(doc: &Document) -> Vec { // Articles, when there are any to export, decide the order (and what's included). if doc.articles.iter().any(|a| a.export) { + let hidden_layers: Vec<_> = doc.layers.iter().filter(|l| !l.visible).map(|l| l.id).collect(); let mut out = Vec::new(); let mut seen = Vec::new(); for a in doc.articles.iter().filter(|a| a.export) { for id in &a.items { let Some(top) = doc.item(*id) else { continue }; + // A hidden item or group hides everything in it. The layer that counts is the + // top-level item's: pages draw a group's members on the group's layer. + if doc.find(*id).is_some_and(|loc| { + (1..=loc.path.len()).any(|n| { + doc.item_at(&designcraft_doc::ItemLoc { spread: loc.spread, path: loc.path[..n].to_vec() }) + .is_some_and(|g| g.hidden || n == 1 && hidden_layers.contains(&g.layer)) + }) + }) { + continue; + } let mut push = |it: &Item| match &it.content { Content::Text(tf) if !seen.contains(&tf.story) => { seen.push(tf.story); @@ -27,11 +38,21 @@ pub fn reading_order(doc: &Document) -> Vec { Content::Graphic(_) => out.push(Block::Image(it.id)), _ => {} }; - top.walk(&mut |i| push(i)); + // Hidden items (with everything in them) aren't on the page. + let mut stack = vec![top]; + while let Some(it) = stack.pop() { + if it.hidden { + continue; + } + push(it); + stack.extend(it.children().iter().rev().map(|c| &**c)); + } } } return out; } + let hidden_layers: Vec<_> = doc.layers.iter().filter(|l| !l.visible).map(|l| l.id).collect(); + let shown = shown_items(doc, &hidden_layers); let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); let mut seen_stories = Vec::new(); for (si, sp) in doc.spreads.iter().enumerate() { @@ -45,7 +66,8 @@ pub fn reading_order(doc: &Document) -> Vec { continue; }; let xf = *parent * it.xf; - if it.hidden { + // Hidden items, and top-level items on hidden layers (with everything in them), aren't on the page. + if it.hidden || stack.len() == 1 && hidden_layers.contains(&it.layer) { continue; } let children = it.children(); @@ -55,8 +77,13 @@ pub fn reading_order(doc: &Document) -> Vec { let block = match &it.content { Content::Text(tf) => { let Some(story) = doc.story(tf.story) else { continue }; - // A story is placed where its first frame is. - if story.frames.first() != Some(&it.id) || seen_stories.contains(&tf.story) || story.text.trim().is_empty() { + // A story is placed where its first frame on the page is: a story is left out + // only when every frame of it is hidden, and a hidden first frame does not hide + // the rest of the thread. + if story.frames.iter().find(|f| shown.contains(f)) != Some(&it.id) + || seen_stories.contains(&tf.story) + || story.text.trim().is_empty() + { continue; } seen_stories.push(tf.story); @@ -78,6 +105,22 @@ pub fn reading_order(doc: &Document) -> Vec { keyed.into_iter().map(|(_, b)| b).collect() } +/// The items the page shows: not hidden themselves, in a hidden group, or (top level) on a hidden layer. +fn shown_items(doc: &Document, hidden_layers: &[designcraft_doc::LayerId]) -> std::collections::HashSet { + let mut shown = std::collections::HashSet::new(); + for sp in &doc.spreads { + let mut stack: Vec<(&Item, bool)> = sp.items.iter().map(|i| (&**i, true)).collect(); + while let Some((it, top)) = stack.pop() { + if it.hidden || top && hidden_layers.contains(&it.layer) { + continue; + } + shown.insert(it.id); + stack.extend(it.children().iter().map(|c| (&**c, false))); + } + } + shown +} + /// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name /// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same /// name apart. diff --git a/crates/epub/src/story_writer.rs b/crates/epub/src/story_writer.rs index d6fb05159..31ca17ebc 100644 --- a/crates/epub/src/story_writer.rs +++ b/crates/epub/src/story_writer.rs @@ -120,9 +120,15 @@ impl<'a, 'f> StoryWriter<'a, 'f> { let mut lead = lead; let mut list: Option<&str> = None; let mut pre: Option<(String, bool)> = None; + let runs: Vec<(std::ops::Range, &story::CharFormat)> = st.runs().collect(); for (pi, (r, pf)) in st.para_ranges().into_iter().zip(&st.paras).enumerate() { let Some(text) = st.text.get(r.clone()) else { continue }; - let table = pf.table.and_then(|id| st.tables.get(&id)).filter(|_| text.contains(story::TABLE_ANCHOR)); + let base = doc.styles.resolve_para(pf).1; + // A table whose anchor is hidden or deleted is off the page, so it stays out. + let table = pf + .table + .and_then(|id| st.tables.get(&id)) + .filter(|_| text.find(story::TABLE_ANCHOR).is_some_and(|at| !doc.hides_run(&base, st.char_format_of(r.start + at)))); let only_table = table.is_some() && text.chars().all(|c| c == story::TABLE_ANCHOR || c.is_whitespace()); if !only_table || !lead.is_empty() { // Export Tagging: the style's element and class. @@ -167,7 +173,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { lead = ""; let mut after = String::new(); if !only_table { - self.runs(st, r, depth, out, &mut after); + self.runs(st, &runs, r, &base, depth, out, &mut after); } if tag == "pre" { // Closed when the next paragraph isn't a line of the same block. @@ -224,17 +230,29 @@ impl<'a, 'f> StoryWriter<'a, 'f> { t } - /// The runs of paragraph `para` of `st`; footnotes go to `after`. - fn runs(&mut self, st: &Story, para: std::ops::Range, depth: usize, out: &mut String, after: &mut String) { + /// The runs of paragraph `para` of `st` (`runs`: the story's, collected once; `base`: the + /// paragraph's base character attributes); footnotes go to `after`. + #[allow(clippy::too_many_arguments)] + fn runs( + &mut self, + st: &Story, + runs: &[(std::ops::Range, &story::CharFormat)], + para: std::ops::Range, + base: &designcraft_doc::CharProps, + depth: usize, + out: &mut String, + after: &mut String, + ) { let doc = self.doc; - for (rr, f) in st.runs() { + let first = runs.partition_point(|(rr, _)| rr.end <= para.start); + for (rr, f) in runs.iter().skip(first).take_while(|(rr, _)| rr.start < para.end).map(|(rr, f)| (rr.clone(), *f)) { let a = rr.start.max(para.start); let b = rr.end.min(para.end); if a >= b { continue; } - // Hidden conditional text isn't exported. - if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { + // Hidden conditional text (the styles' conditions included) and deletions aren't exported. + if doc.hides_run(base, f) { continue; } let Some(raw) = st.text.get(a..b) else { continue }; diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index 62683f91a..62195aec0 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -1025,3 +1025,72 @@ fn hidden_group_children_are_not_exported() { assert!(html::reading_order(&grouped_text(true)).is_empty()); assert_eq!(html::reading_order(&grouped_text(false)).len(), 1); } + +/// Text hidden by a condition the paragraph style carries, and text marked deleted, are off the +/// page: neither the HTML body nor the navigation labels show them. A frame on a hidden layer +/// isn't exported at all. +#[test] +fn text_hidden_by_inherited_conditions_or_deletion_is_not_exported() { + use designcraft_doc::{ChangeMark, CharAttrs, Condition, ParagraphStyle}; + let mut d = Document::new(&NewDocument::default()); + d.conditions.push(Condition { name: "Secret".into(), color: [0, 0, 0], visible: false }); + d.styles_mut().paragraph.push(ParagraphStyle { + name: "Classified".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs { conditions: Some(vec!["Secret".into()]), ..Default::default() }, + shortcut: String::new(), + }); + let lid = d.default_layer(); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 100.0), + lid, + "Public heading\nclassified line\nkept dropped", + ParaFormat::default(), + ) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + let c = st.text.find("classified").unwrap(); + st.format_paras(c..c + 1, |p| p.style = "Classified".into()); + let g = st.text.find(" dropped").unwrap(); + st.format_chars(g..g + 8, |f| f.over = CharAttrs { change: Some(ChangeMark::Deleted), ..Default::default() }); + let hidden_layer = designcraft_doc::LayerId(d.alloc()); + d.layers.push(designcraft_doc::Layer { + id: hidden_layer, + name: "Hidden".into(), + color: [0, 0, 0], + visible: false, + locked: false, + printable: true, + show_guides: true, + suppress_wrap_when_hidden: false, + }); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 300.0), hidden_layer, "On a hidden layer", ParaFormat::default()).unwrap(); + let html = html::export_html(&d, &html::HtmlOptions::default()); + assert!(html.contains("Public heading") && html.contains("kept"), "{html}"); + for gone in ["classified line", "dropped", "On a hidden layer"] { + assert!(!html.contains(gone), "{gone} must not be exported: {html}"); + } + let labels: Vec = chapters::toc_entries(&d, &html::reading_order(&d)).into_iter().map(|(_, label, _)| label).collect(); + assert_eq!(labels, ["Public heading"]); +} + +/// A threaded story is left out only when every one of its frames is hidden: a hidden first +/// frame does not hide the frames after it. +#[test] +fn a_threaded_story_is_skipped_only_when_every_frame_is_hidden() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 300.0), lid, "second", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + let story = |d: &Document| html::reading_order(d).iter().filter(|x| **x == html::Block::Story(sid)).count(); + assert_eq!(story(&d), 1); + d.item_mut(a).unwrap().hidden = true; + assert_eq!(story(&d), 1, "the visible second frame keeps the story"); + d.item_mut(b).unwrap().hidden = true; + assert_eq!(story(&d), 0); +} diff --git a/crates/epub/tests/it/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs index c4689cf20..eb820883a 100644 --- a/crates/epub/tests/it/grouped_reading_order.rs +++ b/crates/epub/tests/it/grouped_reading_order.rs @@ -145,10 +145,13 @@ fn articles_keep_their_explicit_order_and_exclusions() { let (c, _) = text(&mut doc, 0, 36.0, 10.0, "Article third"); let (omitted, _) = text(&mut doc, 0, 36.0, 5.0, "Excluded story"); doc.articles.push(Article { name: "Unused".into(), items: vec![omitted], export: false }); - doc.articles.push(Article { name: "Chosen".into(), items: vec![grouped, c, grouped], export: true }); + let (hidden, _) = text(&mut doc, 0, 36.0, 1.0, "Hidden in article"); + doc.item_mut(hidden).unwrap().hidden = true; + doc.articles.push(Article { name: "Chosen".into(), items: vec![grouped, c, grouped, hidden], export: true }); assert_output_order(&doc, &["Article first", "Article second", "Article third"]); for output in outputs(&doc) { assert!(!output.contains("Excluded story")); + assert!(!output.contains("Hidden in article")); } } diff --git a/crates/textimport/src/export.rs b/crates/textimport/src/export.rs index 2493f7f98..8d38780d1 100644 --- a/crates/textimport/src/export.rs +++ b/crates/textimport/src/export.rs @@ -20,33 +20,35 @@ fn plain_char(c: char) -> Option<&'static str> { } } -fn table_text(story: &Story, id: u64, nl: &str) -> String { +/// Cells of table `id` as rows of tab-separated cell text (the cells' visible text, nested +/// tables and notes left out), `nl` between rows. +fn table_text(doc: &Document, story: &Story, id: u64, nl: &str) -> String { let Some(t) = story.tables.get(&id) else { return String::new() }; (0..t.nrows()) - .map(|r| (0..t.ncols()).map(|c| t.cell(r, c).map(|x| x.text.text.replace('\n', " ")).unwrap_or_default()).collect::>().join("\t")) + .map(|r| { + (0..t.ncols()) + .map(|c| t.cell(r, c).map(|x| plain_text(doc, &x.text).replace("\r\n", " ").replace('\n', " ")).unwrap_or_default()) + .collect::>() + .join("\t") + }) .collect::>() .join(nl) } -/// Text Only: the story's text, CRLF between paragraphs. -pub fn plain_text(story: &Story) -> String { - // Tracked deletions aren't part of the text. - let deleted: Vec> = - story.runs().filter(|(_, f)| f.over.change == Some(designcraft_doc::ChangeMark::Deleted)).map(|(r, _)| r).collect(); +/// Text Only: the story's text as it shows (hidden conditional text and tracked deletions left +/// out), CRLF between paragraphs. +pub fn plain_text(doc: &Document, story: &Story) -> String { let mut out = String::with_capacity(story.text.len() + 16); + let spans = doc.visible_spans(story); for (i, r) in story.para_ranges().into_iter().enumerate() { if i > 0 { out.push_str("\r\n"); } - let start = r.start; - for (ci, c) in story.text[r].char_indices() { - if deleted.iter().any(|d| d.contains(&(start + ci))) { - continue; - } + for c in Document::visible_text(story, &spans, r).chars() { match (c, plain_char(c)) { (TABLE_ANCHOR, _) => { if let Some(id) = story.paras.get(i).and_then(|p| p.table) { - out.push_str(&table_text(story, id, "\r\n")); + out.push_str(&table_text(doc, story, id, "\r\n")); } } (_, Some("")) => out.push(c), @@ -178,7 +180,7 @@ pub fn rtf(doc: &Document, story: &Story) -> String { match c { TABLE_ANCHOR => { if let Some(id) = table { - for ch in table_text(story, *id, "\u{2028}").chars() { + for ch in table_text(doc, story, *id, "\u{2028}").chars() { rtf_escape(&mut out, ch); } } @@ -214,14 +216,42 @@ mod tests { #[test] fn text_variable_markers_are_dropped() { let st = Story::with_text(StoryId(1), "Page \u{E100} of", Default::default()); - assert_eq!(plain_text(&st), "Page of"); + assert_eq!(plain_text(&Document::new(&NewDocument::default()), &st), "Page of"); let r = rtf(&Document::new(&NewDocument::default()), &st); assert!(!r.contains("\\u-7936") && r.contains("Page of"), "{r}"); } #[test] fn text_only() { - assert_eq!(plain_text(&story()), "Plain bold {x}\r\nCafé\ntwo"); + assert_eq!(plain_text(&Document::new(&NewDocument::default()), &story()), "Plain bold {x}\r\nCafé\ntwo"); + } + + /// Text hidden by a condition set on the paragraph style, and text marked deleted, don't + /// reach Text Only: not in the story and not in a table cell. + #[test] + fn text_only_leaves_out_hidden_and_deleted_text() { + let mut doc = Document::new(&NewDocument::default()); + doc.conditions.push(designcraft_doc::Condition { name: "Secret".into(), color: [0, 0, 0], visible: false }); + doc.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: "Hidden".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs { conditions: Some(vec!["Secret".into()]), ..Default::default() }, + shortcut: String::new(), + }); + let mut st = Story::with_text(StoryId(1), "shown\nhidden by style\nkept gone", Default::default()); + let h = st.text.find("hidden").unwrap(); + st.format_paras(h..h + 1, |p| p.style = "Hidden".into()); + let g = st.text.find(" gone").unwrap(); + st.format_chars(g..g + 5, |f| f.over = CharAttrs { change: Some(designcraft_doc::ChangeMark::Deleted), ..Default::default() }); + assert_eq!(plain_text(&doc, &st), "shown\r\n\r\nkept"); + let mut outer = Story::with_text(StoryId(2), &TABLE_ANCHOR.to_string(), Default::default()); + let mut t = designcraft_doc::Table::new(7, 1, 1, 0, 0, 100.0); + t.cells[0].text = st.clone(); + outer.tables.insert(7, std::sync::Arc::new(t)); + outer.paras[0].table = Some(7); + assert_eq!(plain_text(&doc, &outer), "shown kept"); } #[test] diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index c3668614d..572c732c8 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -74,13 +74,15 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { out.push_str("\r\n"); } let pf = story.paras.get(i).cloned().unwrap_or_default(); + let base = doc.styles.resolve_para(&pf).1; let _ = write!(out, "", escape_value(&pf.style)); for (rr, f) in story.runs() { let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); if a >= b { continue; } - if doc.conditions_hide(f.over.conditions.as_deref().unwrap_or(&[])) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { + // Hidden conditional text (the styles' conditions included) and deletions aren't exported. + if doc.hides_run(&base, f) { continue; } let mut open = Vec::new();