mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
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>
This commit is contained in:
1 parent
b7174dc92d
commit
ff0463e299
10 files changed
+281
-33
No files matched your search
@@ -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<std::ops::Range<usize>> {
|
||||
let mut out: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
let runs: Vec<(std::ops::Range<usize>, &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<usize>], range: std::ops::Range<usize>) -> 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
|
||||
|
||||
@@ -142,7 +142,7 @@ pub(super) fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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');
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+47
-4
@@ -14,11 +14,22 @@ pub enum Block {
|
||||
pub fn reading_order(doc: &Document) -> Vec<Block> {
|
||||
// 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<Block> {
|
||||
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<Block> {
|
||||
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<Block> {
|
||||
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<Block> {
|
||||
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<ItemId> {
|
||||
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.
|
||||
|
||||
@@ -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<usize>, &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<usize>, 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<usize>, &story::CharFormat)],
|
||||
para: std::ops::Range<usize>,
|
||||
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 };
|
||||
|
||||
@@ -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<String> = 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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<Vec<_>>().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::<Vec<_>>()
|
||||
.join("\t")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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<std::ops::Range<usize>> =
|
||||
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]
|
||||
|
||||
@@ -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, "<ParaStyle:{}>", 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();
|
||||
|
||||
Reference in new issue
Block a user