diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 86687a3a1..39e2c079b 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -375,6 +375,37 @@ impl Paragraph { } out } + /// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted. + pub fn final_text(&self) -> String { + self.text_without(&self.deleted_ranges()) + } + /// The byte ranges of the tracked deletions. + pub fn deleted_ranges(&self) -> Vec> { + self.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| r).collect() + } + /// [`Self::plain_text`] without the text in the byte ranges `dropped` (in any order, overlapping + /// or not). + pub fn text_without(&self, dropped: &[std::ops::Range]) -> String { + let mut sorted = dropped.to_vec(); + sorted.sort_unstable_by_key(|r| r.start); + let mut next = sorted.iter().peekable(); + let mut out = String::with_capacity(self.text.len()); + let mut k = 0; + for (i, c) in self.text.char_indices() { + // By start: once the ranges that end by `i` are skipped, `i` is dropped if the next has begun. + while next.next_if(|r| r.end <= i).is_some() {} + let keep = !next.peek().is_some_and(|r| r.start <= i); + if c == OBJ { + if keep && let Some(o) = self.objects.get(k) { + out.push_str(o.plain_text()); + } + k += 1; + } else if keep { + out.push(c); + } + } + out + } fn check(&self, off: usize) -> Result<()> { if off > self.text.len() || !self.text.is_char_boundary(off) { @@ -788,6 +819,37 @@ mod tests { CharProps { bold: Some(true), ..Default::default() } } + #[test] + fn text_without_takes_ranges_in_any_order() { + // Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty. + let mut p = Paragraph::with_text("añb€c", CharProps::default()); + p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false }, &CharProps::default()).unwrap(); + let text = p.text.clone(); + let naive = |dropped: &[std::ops::Range]| { + let mut out = String::new(); + let mut k = 0; + for (i, c) in text.char_indices() { + let keep = !dropped.iter().any(|r| r.contains(&i)); + if c == OBJ { + if keep && let Some(o) = p.objects.get(k) { + out.push_str(o.plain_text()); + } + k += 1; + } else if keep { + out.push(c); + } + } + out + }; + let n = text.len(); + let cases: Vec>> = + vec![vec![], vec![1..3], vec![5..n, 0..1], vec![0..4, 2..3, 3..6], vec![2..9, 1..2, 4..4, 0..n], vec![6..6, 9..n, 0..0], vec![3..n + 10]]; + for dropped in cases { + assert_eq!(p.text_without(&dropped), naive(&dropped), "{dropped:?} of {text:?}"); + } + assert_eq!(p.text_without(std::slice::from_ref(&(1..3))), "a7b€c"); + } + #[test] fn insert_and_runs() { let mut p = Paragraph::with_text("Hello world", CharProps::default()); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index dd98baf06..5c5904706 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -34,6 +34,8 @@ pub fn specs() -> Vec { CommandSpec::new("review.previousChange", "Previous Change", "Review › Changes", |s, _| nav_change(s, -1)).pure(), CommandSpec::new("review.markup", "Display for Review", "Review › Tracking", |s, v| { s.view.show_markup = p::str(v, "value").map(|m| m != "noMarkup" && m != "original").unwrap_or(!s.view.show_markup); + // Without markup, deletions leave the layout. + s.relayout(); Ok(json!({"showMarkup": s.view.show_markup})) }) .pure(), diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 686d939f6..f06e18889 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -372,6 +372,7 @@ pub fn specs() -> Vec { .params(r#"{"lang": "en-US|en-GB|fr-FR|…", "noProof"?: bool}"#), CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| { s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup); + s.relayout(); Ok(json!({"value": s.view.show_markup})) }) .pure(), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f22973ee0..fbdae6873 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -231,14 +231,20 @@ impl Session { { return l.clone(); } - let opts = LayoutOptions { view: self.view.mode, web_width: ww, show_hidden: self.view.marks, proofing: self.view.proofing }; + let opts = LayoutOptions { + view: self.view.mode, + web_width: ww, + show_hidden: self.view.marks, + hide_deleted: !self.view.show_markup, + proofing: self.view.proofing, + }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing)); l } /// A layout for output (PDF, images, print): no proofing marks, print view. pub fn export_layout(&self) -> Arc { - let opts = LayoutOptions { view: ViewMode::Print, web_width: 0.0, show_hidden: false, proofing: false }; + let opts = LayoutOptions { view: ViewMode::Print, web_width: 0.0, show_hidden: false, hide_deleted: false, proofing: false }; Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0656cfee9..d1b07fa9d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -486,6 +486,27 @@ fn track_changes_and_accept() { assert_eq!(text(&s), "inal added"); } +#[test] +fn no_markup_view_lays_out_the_final_text() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "original"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.text", json!({"text": "orig"})); + run(&mut s, "text.delete", json!({})); + let gap = |s: &mut Session| { + let l = s.layout(); + let x = |off| l.caret(&Pos::body(0, off)).map(|c| c.x).unwrap_or(f32::NAN); + x(4) - x(0) + }; + assert!(gap(&mut s) > 5.0, "markup shows the deletion"); + run(&mut s, "review.markup", json!({"value": "noMarkup"})); + assert!(gap(&mut s).abs() < 0.01, "No Markup leaves it out"); + run(&mut s, "review.showMarkup", json!({"value": true})); + assert!(gap(&mut s) > 5.0); + run(&mut s, "review.showMarkup", json!({"value": false})); + assert!(gap(&mut s).abs() < 0.01); +} + #[test] fn replace_all_is_tracked() { let mut s = s(); diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 163724ffa..0f349a212 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -313,6 +313,10 @@ fn lines( } let run_end = k; let rc = &st.rc; + // Without markup a deletion is never drawn as ordinary text, even in a layout that kept it. + if rc.del.is_some() && !opts.markup { + continue; + } let rev = rc.ins.or(rc.del).filter(|_| opts.markup); let color = match rev { Some(r) => revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)), diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9ce2bd919..72356b405 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -46,6 +46,8 @@ pub struct LayoutOptions { /// Width of the window in Web/Draft views, points. pub web_width: f32, pub show_hidden: bool, + /// Final text ("No Markup"): tracked deletions take no space and draw nothing. + pub hide_deleted: bool, /// Check spelling and grammar (squiggles). pub proofing: bool, } @@ -195,7 +197,7 @@ fn hash_of(t: &T) -> u64 { fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default(); - hash_of(&(s, opts.show_hidden, opts.proofing, wordcraft_proof::user_dictionary().len(), doc.settings.auto_hyphenation)) + hash_of(&(s, opts.show_hidden, opts.hide_deleted, opts.proofing, wordcraft_proof::user_dictionary().len(), doc.settings.auto_hyphenation)) } fn has_page_fields(p: &Paragraph) -> bool { @@ -236,6 +238,7 @@ impl Ctx<'_> { label: None, fields: &self.fields, show_hidden: false, + hide_deleted: false, table_chr: None, proofing: false, exclusions: &[], @@ -284,6 +287,7 @@ impl Ctx<'_> { label, fields: &self.fields, show_hidden: self.opts.show_hidden, + hide_deleted: self.opts.hide_deleted, table_chr, proofing: self.opts.proofing, exclusions, @@ -294,13 +298,57 @@ impl Ctx<'_> { } } -/// Note part ids in document order → numbers (footnotes and endnotes numbered separately). -fn note_numbers(doc: &Document) -> HashMap { +/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden +/// text unless `show_hidden`, or in a tracked deletion with `hide_deleted`. `table_chr` is the +/// table style's character formatting under the runs' own, as the paragraph is laid out with. +fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool { + let mut left = Vec::new(); + // Nothing can be left out with hidden text shown and markup on. + if (hide_deleted || !show_hidden) && !p.objects.is_empty() { + let style = p.props.style.as_deref(); + let is_left = |c: &CharProps| { + let rc = match table_chr { + Some(t) => doc.styles.resolve_char(style, &t.clone().overlaid(c)), + None => doc.styles.resolve_char(style, c), + }; + para::left_out(&rc, show_hidden, hide_deleted) + }; + // Runs and objects are both in order: one walk finds each object's run (past the last run, + // the paragraph mark, as `props_of_char` gives), resolving a run's formatting only once. + let mut runs = p.run_ranges().peekable(); + let mut last: Option<(usize, bool)> = None; + for off in p.object_offsets() { + while runs.next_if(|(r, _)| r.end <= off).is_some() {} + let l = match runs.peek() { + Some((r, c)) => match last { + Some((end, l)) if end == r.end => l, + _ => { + let l = is_left(c); + last = Some((r.end, l)); + l + } + }, + None => is_left(&p.mark), + }; + left.push(l); + } + } + move |k| left.get(k).copied().unwrap_or(false) +} + +/// Note part ids in document order → numbers (footnotes and endnotes numbered separately). With +/// `hide_deleted`, notes whose reference mark is a tracked deletion are left out, as in the final text. +fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap { let mut m = HashMap::new(); let (mut f, mut e) = (0u32, 0u32); for path in doc.para_paths(StoryRef::Body) { if let Some(p) = doc.para(StoryRef::Body, &path) { - for o in &p.objects { + // A hidden reference mark still takes its number, as in Word; a deleted one does not. + let deleted = left_out_objects(doc, p, None, true, hide_deleted); + for (k, o) in p.objects.iter().enumerate() { + if deleted(k) { + continue; + } if let InlineObject::NoteRef { kind, id, .. } = o { let n = match kind { wordcraft_doc::para::NoteKind::Footnote => { @@ -358,9 +406,14 @@ fn layout_box( // Word: space between paragraphs is before + after (no collapsing). y += if i == 0 { before } else { before.max(0.0) }; // Floating objects anchored here: place them, then wrap the text around them. + // One left out of the layout (hidden, or deleted in the final text) takes no room. let mut floats = Vec::new(); + let left_out = left_out_objects(ctx.doc, p, table_chr, ctx.opts.show_hidden, ctx.opts.hide_deleted); for (oi, o) in p.objects.iter().enumerate() { let Some((w, h, float)) = floating(o) else { continue }; + if left_out(oi) { + continue; + } let r = float_rect(frame, (0.0, width), y, w, h, float); excl.extend(wrap_area(r, float)); floats.push((oi, r, float.wrap == Wrap::BehindText)); @@ -602,7 +655,7 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> cache.env = env; } cache.used.clear(); - let notes = note_numbers(doc); + let notes = note_numbers(doc, opts.hide_deleted); let notes_hash = hash_of(&{ let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, *b)).collect(); v.sort(); @@ -907,8 +960,14 @@ fn anchor_floats( let mut rects = HashMap::new(); if !pb.web { let frame = PageFrame { sect: pb.sect, origin: (0.0, 0.0) }; + // An object left out of the layout (hidden, or deleted in the final text) is not placed, so + // it takes no room either. + let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.opts.hide_deleted); for (oi, o) in p.objects.iter().enumerate() { let Some((w, h, float)) = floating(o) else { continue }; + if left_out(oi) { + continue; + } let r = float_rect(Some(frame), (col_x, width), y0, w, h, float); rects.insert(oi, r); if let Some(area) = wrap_area(r, float) { @@ -1145,6 +1204,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { label: None, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, + hide_deleted: ctx.opts.hide_deleted, table_chr: None, proofing: false, exclusions: &[], @@ -1192,6 +1252,8 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl for (ri, row) in tl.rows.iter().enumerate() { let splittable = !header_rows.contains(&ri) && !t.rows.get(ri).is_some_and(|r| r.props.cant_split); let mut rest: Option = None; + // On a new page holding only the repeated header rows. + let mut fresh = false; // Each pass places the part of the row that fits, then breaks the page. for _ in 0..1000 { let cur = rest.as_ref().unwrap_or(row); @@ -1204,7 +1266,9 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl place(pb, &a); rest = Some(b); } - None if pb.at_top() => break, + // Like Word, a row that does not fit on an empty page, or under the header rows + // repeated there, is placed anyway and runs into the bottom margin. + None if pb.at_top() || fresh => break, None => {} } pb.advance(block, body_top); @@ -1216,6 +1280,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl } } } + fresh = true; } place(pb, rest.as_ref().unwrap_or(row)); } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index cfab8671e..ce4ebf8f0 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -138,6 +138,9 @@ pub struct ParaLayout { pub drop_cap: Option<(usize, u8, f32)>, /// Clusters after which the line may break with a hyphen (soft hyphens, auto hyphenation), sorted. pub hyph_after: Vec, + /// Byte ranges of the text laid out as nothing ([`left_out`]): hidden text that is not shown, + /// tracked deletions in the final text. Resolved as laid out, table formatting included. + pub left_out: Vec>, /// The text drawn by clusters that stand for an object (field results, note numbers, /// equations) rather than for the paragraph's own text, by cluster index, sorted. pub shown: Vec<(usize, String)>, @@ -151,6 +154,8 @@ pub struct ParaEnv<'a> { pub label: Option<(String, Level)>, pub fields: &'a FieldCtx, pub show_hidden: bool, + /// Leave tracked deletions out, like hidden text (the final, "No Markup" text). + pub hide_deleted: bool, /// Extra style applied to every run (table style conditional formatting), under direct formatting. pub table_chr: Option<&'a CharProps>, pub proofing: bool, @@ -170,14 +175,11 @@ pub struct Exclusion { pub top_bottom: bool, } -fn style_key(rc: &ResolvedChar) -> String { - format!("{}|{}|{}|{}|{}", rc.font, rc.size, rc.bold, rc.italic, rc.vert_align as u8) -} - struct Builder<'a> { env: &'a ParaEnv<'a>, styles: Vec, - style_index: std::collections::HashMap<(String, u32, bool), u16>, + /// Hash of a style's format → (index in `styles`, small caps), see `style`. + style_index: std::collections::HashMap>, glyphs: Vec, clusters: Vec, /// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`). @@ -189,14 +191,28 @@ impl<'a> Builder<'a> { fn style(&mut self, rc: &Arc, face_override: Option, small: bool) -> u16 { let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); let face = face_override.unwrap_or(r.face); - let key = ( - format!("{}|{:?}|{}|{}", style_key(rc), rc.color, rc.underline as u8, small), - face.id(), - rc.strike || rc.double_strike || rc.link.is_some(), - ); - let key = (format!("{}|{:?}|{:?}|{:?}|{:?}|{}|{:?}", key.0, rc.highlight, rc.shading, rc.ins, rc.del, rc.hidden, rc.border), key.1, key.2); - if let Some(i) = self.style_index.get(&key) { - return *i; + // The display list reads every run's format (link target, strike kind, underline colour, + // baseline shift…) from its StyleRun, so runs share one only when their formats are equal. + // Keyed by a hash of the commonly varying fields (no allocation: this runs for every shaped + // piece), then confirmed by comparing whole formats within the bucket. + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (face.id(), small, &rc.font, rc.size.to_bits(), rc.bold, rc.italic, rc.color, rc.highlight, rc.shading).hash(&mut h); + (rc.underline_color, rc.strike, rc.double_strike, &rc.link, rc.ins, rc.del, rc.hidden).hash(&mut h); + h.finish() + }; + let bucket = self.style_index.entry(key).or_default(); + // Formats that never compare equal (a NaN from a hostile file) get their own styles; a capped + // scan keeps such a bucket from turning quadratic. + for &(i, s) in bucket.iter().rev().take(64) { + if s == small + && let Some(st) = self.styles.get(i as usize) + && st.face.id() == face.id() + && (Arc::ptr_eq(&st.rc, rc) || *st.rc == **rc) + { + return i; + } } let size = rc.draw_size() * if small { 0.8 } else { 1.0 }; let (a, d) = wordcraft_fonts::word::line_metrics(&face); @@ -213,7 +229,7 @@ impl<'a> Builder<'a> { }; let i = self.styles.len().min(u16::MAX as usize) as u16; self.styles.push(st); - self.style_index.insert(key, i); + self.style_index.entry(key).or_default().push((i, small)); i } @@ -336,6 +352,12 @@ impl<'a> Builder<'a> { } } +/// Whether text with this formatting is left out of the layout: hidden text unless it is shown, and +/// tracked deletions in the final text. It takes no room, draws nothing and is no place to break. +pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool) -> bool { + (rc.hidden && !show_hidden) || (rc.del.is_some() && hide_deleted) +} + /// Lay out one paragraph. pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let doc = env.doc; @@ -372,16 +394,34 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { .filter(|c| !matches!(*c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r' | ' ')) .filter(|c| p.text.len() > c.len_utf8()); let mut drop_cap = None; + // The byte ranges of runs left out of the layout, in order, adjacent runs merged. + let mut left: Vec> = Vec::new(); for (range, props) in p.run_ranges() { let rc = resolve(props); let Some(text) = p.text.get(range.clone()) else { continue }; - if rc.hidden && !b.env.show_hidden { + if left_out(&rc, b.env.show_hidden, b.env.hide_deleted) { + match left.last_mut() { + Some(last) if last.end == range.start => last.end = range.end, + _ => left.push(range.clone()), + } let si = b.style(&rc, None, false); let g = b.glyphs.len() as u32; + // Word still prints the note of a hidden reference mark (and numbers it); a deleted + // reference is gone from the final text, note and all. + let keeps_notes = !(rc.del.is_some() && b.env.hide_deleted); for (i, c) in text.char_indices() { + let mut note = None; if c == OBJ { + if let Some(InlineObject::NoteRef { id, .. }) = p.objects.get(obj_index) + && keeps_notes + { + note = Some(*id); + } obj_index += 1; } + if let Some(id) = note { + notes.push((b.clusters.len(), id)); + } b.clusters.push(Cluster { start: range.start + i, end: range.start + i + c.len_utf8(), @@ -492,15 +532,41 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { b.shape(s, range.start + seg, &rc, None); } } - // Break opportunities. + // Break opportunities and hyphenation points come from the text as laid out: hidden text that is + // not shown and, without markup, tracked deletions are left out, so a space or soft hyphen there + // is no place to break, and words are hyphenated as they read. + // The text and, for each char of it, its end there and in `p.text`. + let (text, ends): (std::borrow::Cow, Vec<(usize, usize)>) = if left.is_empty() { + (p.text.as_str().into(), Vec::new()) + } else { + let (mut text, mut ends) = (String::new(), Vec::new()); + let mut next = left.iter().peekable(); + for (i, c) in p.text.char_indices() { + // `left` is in order: skip the ranges that end here, then is `i` in the next one? + while next.next_if(|r| r.end <= i).is_some() {} + if next.peek().is_some_and(|r| r.start <= i) { + continue; + } + text.push(c); + ends.push((text.len(), i + c.len_utf8())); + } + (text.into(), ends) + }; + // The end of a char in `text` → the same place in `p.text`. + let to_para = |i: usize| -> Option { + if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) } + }; let mut opps = std::collections::HashSet::new(); - for (i, o) in unicode_linebreak::linebreaks(&p.text) { + for (i, o) in unicode_linebreak::linebreaks(&text) { // Word keeps "and/or" and web addresses whole: no break right after a slash (a word // too long for the line still breaks anywhere). let after_slash = - p.text.get(..i).is_some_and(|t| t.ends_with('/')) && p.text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric); - if (o == unicode_linebreak::BreakOpportunity::Allowed || i < p.text.len()) && !after_slash { - opps.insert(i); + text.get(..i).is_some_and(|t| t.ends_with('/')) && text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric); + if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len()) + && !after_slash + && let Some(end) = to_para(i) + { + opps.insert(end); } } for c in &mut b.clusters { @@ -555,8 +621,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { issues: if env.proofing { proof_issues(p) } else { Vec::new() }, drop_cap, hyph_after: Vec::new(), + left_out: Vec::new(), }; - pl.hyph_after = hyphenation_points(p, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens); + pl.hyph_after = hyphenation_points(&text, to_para, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens); + pl.left_out = left; for k in pl.hyph_after.clone() { if let Some(c) = pl.clusters.get_mut(k as usize) { c.break_after = true; @@ -1076,13 +1144,14 @@ fn hyphen_glyph(pl: &ParaLayout, style: u16, cache: &mut Vec<(u16, u32, f32)>) - } /// Clusters after which a line may end with a hyphen: soft hyphens always, and dictionary or -/// pattern hyphenation points of each word when automatic hyphenation is on. -fn hyphenation_points(p: &Paragraph, pl: &ParaLayout, auto: bool) -> Vec { - let mut bytes: Vec = p.text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect(); +/// pattern hyphenation points of each word when automatic hyphenation is on. Both are found in +/// `text`, the paragraph as laid out (without what the layout leaves out, [`left_out`]), and +/// `to_para` maps the end of a char there to the paragraph's text. +fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option, pl: &ParaLayout, auto: bool) -> Vec { + let mut bytes: Vec = text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect(); if auto { let lim = wordcraft_proof::hyphen::Limits::default(); let mut start: Option = None; - let text = &p.text; for (i, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { let word_char = c.is_alphabetic() || c == '\'' || c == '\u{2019}'; match (start, word_char) { @@ -1104,6 +1173,7 @@ fn hyphenation_points(p: &Paragraph, pl: &ParaLayout, auto: bool) -> Vec { } } } + let mut bytes: Vec = bytes.into_iter().filter_map(to_para).collect(); if bytes.is_empty() { return Vec::new(); } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 41e18f942..28fd33cde 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -62,6 +62,289 @@ fn hit_and_caret_agree() { assert_eq!(up.path, Path::top(0)); } +#[test] +fn deletions_leave_the_final_text_layout() { + // "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED". + let mut d = Document::from_text("Keep DELETEDTEXT INSERTED"); + d.format_range(&Pos::body(0, 5), &Pos::body(0, 17), &|c| c.del = Some(0)).unwrap(); + d.format_range(&Pos::body(0, 17), &Pos::body(0, 25), &|c| c.ins = Some(0)).unwrap(); + let texts = |l: &DocLayout, markup: bool| -> (String, usize) { + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions { markup, ..Default::default() }); + let text = items.iter().filter_map(|i| if let display::Draw::Glyphs { text, .. } = i { Some(text.as_str()) } else { None }).collect(); + (text, items.iter().filter(|i| matches!(i, display::Draw::Line { .. })).count()) + }; + // Markup: the deletion is laid out and struck through, the insertion underlined. + let full = lay(&d); + assert_eq!(texts(&full, true), ("Keep DELETEDTEXT INSERTED".into(), 2)); + // Without markup a full layout still never prints the deletion as plain text. + assert!(!texts(&full, false).0.contains("DELETED")); + // The final layout gives the deletion no width. + let fin = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted: true, ..Default::default() }); + assert_eq!(texts(&fin, false), ("Keep INSERTED".into(), 0)); + let at = |off| fin.caret(&Pos::body(0, off)).unwrap(); + assert!((at(5).x - at(17).x).abs() < 0.01, "{:?} {:?}", at(5), at(17)); + // Every offset maps to a caret, and clicks land outside the hidden text. + for off in 0..=25 { + let c = at(off); + let back = fin.hit(c.page, c.x + 0.1, c.top + c.height / 2.0, StoryRef::Body).unwrap(); + assert!(back.off <= 5 || back.off >= 17, "off {off} hit {back:?}"); + } +} + +#[test] +fn deleted_soft_hyphen_is_no_hyphenation_point_without_markup() { + // "ab\u{ad}cdef" in a column where "ab-" fits but the whole word does not; only the soft + // hyphen is track-deleted. + let mut d = Document::from_text("ab\u{ad}cdef"); + d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.del = Some(0)).unwrap(); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 26.0)).unwrap(); + let hyphens = |hide_deleted: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() }); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + para.lines.iter().filter(|l| l.hyphen.is_some()).count() + }; + // With markup the deleted soft hyphen is still there, and the word breaks at it. + assert_eq!(hyphens(false), 1); + // Final text: the soft hyphen is gone, so is the hyphen. + assert_eq!(hyphens(true), 0); +} + +#[test] +fn deleted_text_adds_no_line_break_opportunity_without_markup() { + // "xx ab\u{ad}cdef" in a column where "xx ab-" and "abcdef" fit but "xx abcdef" does not; only + // the soft hyphen (5..7) is track-deleted. + let mut d = Document::from_text("xx ab\u{ad}cdef"); + d.format_range(&Pos::body(0, 5), &Pos::body(0, 7), &|c| c.del = Some(0)).unwrap(); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 40.0)).unwrap(); + let line_starts = |hide_deleted: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() }); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + para.lines.iter().map(|l| para.clusters.get(l.c0).map_or(usize::MAX, |c| c.start)).collect::>() + }; + // With markup the word breaks at the (deleted) soft hyphen. + assert_eq!(line_starts(false), vec![0, 7]); + // Final text: "abcdef" is one word, so the line breaks at the space before it. + assert_eq!(line_starts(true), vec![0, 3]); +} + +#[test] +fn hidden_soft_hyphen_is_no_hyphenation_point_when_hidden_text_is_not_shown() { + // As with deletions: "ab\u{ad}cdef" where only the soft hyphen is hidden text. + let mut d = Document::from_text("ab\u{ad}cdef"); + d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.hidden = Some(true)).unwrap(); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 26.0)).unwrap(); + let hyphens = |show_hidden: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() }); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + para.lines.iter().filter(|l| l.hyphen.is_some()).count() + }; + // Hidden text shown: the soft hyphen is there, and the word breaks at it. + assert_eq!(hyphens(true), 1); + // Not shown (as printed): no soft hyphen, so no hyphen. Word breaks "abc" / "def" here. + assert_eq!(hyphens(false), 0); +} + +#[test] +fn hidden_text_adds_no_line_break_opportunity_when_not_shown() { + // "xx ab" + hidden soft hyphen or space + "cdef", in a column where "xx ab-" and "abcdef" fit + // but "xx abcdef" does not. Measured in Word (hidden text not printed): "xx" / "abcdef" both times. + for hidden in ["\u{ad}", " "] { + let mut d = Document::from_text(&format!("xx ab{hidden}cdef")); + let end = 5 + hidden.len(); + d.format_range(&Pos::body(0, 5), &Pos::body(0, end), &|c| c.hidden = Some(true)).unwrap(); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 40.0)).unwrap(); + let line_starts = |show_hidden: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() }); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + para.lines.iter().map(|l| para.clusters.get(l.c0).map_or(usize::MAX, |c| c.start)).collect::>() + }; + // Shown, the word breaks at the hidden soft hyphen or space. + assert_eq!(line_starts(true), vec![0, end], "{hidden:?}"); + // Not shown, "abcdef" is one word, so the line breaks at the space before it. + assert_eq!(line_starts(false), vec![0, 3], "{hidden:?}"); + } +} + +/// How much longer laying out `doc(4n)` takes than `doc(n)` (fastest of three each, after a +/// warm-up): ~4 when linear, ~16 when quadratic, whatever the machine. +/// How much bigger the second document is in the linear-time tests. At 8x a linear layout takes +/// about 8x as long and a quadratic one about 64x, so a limit of 24x stays far from both, however +/// busy the machine running the tests is. +const SCALE: usize = 8; + +fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) { + let fastest = |d: &Document| { + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + layout(d, &mut LayoutCache::new(), opts); + t.elapsed().as_secs_f64() + }) + .fold(f64::INFINITY, f64::min) + }; + let (small, big) = (doc(n), doc(SCALE * n)); + layout(&small, &mut LayoutCache::new(), opts); + let (t1, tk) = (fastest(&small), fastest(&big)); + (t1, tk, tk / t1.max(1e-9)) +} + +#[test] +fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() { + // "hyphenation " n times with every space hidden: one joined word of 11n letters reaches the + // hyphenator, whose limit checks once rescanned the word for every candidate point. + let doc = |n: usize| { + let mut d = Document::from_text(&"hyphenation ".repeat(n)); + d.settings.auto_hyphenation = true; + for k in 0..n { + let at = 12 * k + 11; + d.format_range(&Pos::body(0, at), &Pos::body(0, at + 1), &|c| c.hidden = Some(true)).unwrap(); + } + d + }; + let (t1, tk, ratio) = layout_time_ratio(doc, 800, &LayoutOptions::default()); + eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear"); +} + +#[test] +fn many_hidden_float_anchors_lay_out_in_linear_time() { + // n square-wrapped shapes, each anchored in its own hidden run (bold alternating, so runs do not + // merge), markup on: finding each anchor's formatting once rescanned the runs from the start. + let doc = |n: usize| { + let float = wordcraft_doc::para::Float { + wrap: wordcraft_doc::para::Wrap::Square, + h_rel: wordcraft_doc::para::Anchor::Column, + v_rel: wordcraft_doc::para::Anchor::Paragraph, + x: 0.0, + y: 0.0, + dist: 9.0, + dist_top: 9.0, + dist_bottom: 9.0, + ..Default::default() + }; + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::Rectangle, + w: 20.0, + h: 20.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + }; + let obj = wordcraft_doc::para::OBJ.to_string(); + let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default()); + p.text.push_str(&obj.repeat(n)); + p.objects = vec![shape; n]; + p.runs = std::iter::once(wordcraft_doc::Run { len: 5, props: Default::default() }) + .chain((0..n).map(|i| wordcraft_doc::Run { + len: obj.len(), + props: wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 0), ..Default::default() }, + })) + .collect(); + let mut d = Document::from_text(""); + d.body = vec![wordcraft_doc::para_block(p)]; + d + }; + // Correct first: hidden anchors place no shape, shown ones do. + let shapes = |show_hidden: bool| { + let l = layout(&doc(50), &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() }); + l.pages.iter().flat_map(|p| p.items.iter()).filter(|i| matches!(i, Placed::Shape { .. })).count() + }; + assert_eq!((shapes(false), shapes(true)), (0, 50)); + let (t1, tk, ratio) = layout_time_ratio(doc, 10_000, &LayoutOptions::default()); + eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "8x the anchors took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear"); +} + +#[test] +fn fragmented_hidden_text_lays_out_like_the_visible_text() { + // Visible words with a hidden multi-byte char after every visible one, formatted in alternating + // runs (bold on and off) so no two left-out runs are adjacent or merge. + let words = "Hyphenation wraps international words across narrow columns of text ".repeat(6); + let mut d = Document::from_text(""); + d.settings.auto_hyphenation = true; + let mut text = String::new(); + let mut kept = Vec::new(); + for (i, c) in words.chars().enumerate() { + kept.push(text.len()); + text.push(c); + let at = text.len(); + text.push('é'); + let n = d.para(StoryRef::Body, &Path::top(0)).unwrap().len(); + let bold = wordcraft_doc::CharProps { bold: Some(i % 2 == 0), ..Default::default() }; + d.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap(); + let hidden = wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 1), ..Default::default() }; + d.insert_text(&Pos::body(0, at), "é", &hidden).unwrap(); + } + assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, text); + let mut plain = Document::from_text(&words); + plain.settings.auto_hyphenation = true; + for doc in [&mut d, &mut plain] { + doc.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 90.0)).unwrap(); + } + // Line starts and hyphenation points, as paragraph offsets. + let shape = |d: &Document| { + let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default()); + let paras: Vec<_> = l + .pages + .iter() + .flat_map(|p| p.items.iter()) + .filter_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }) + .collect(); + let para = ¶s[0]; + let starts: Vec = para.lines.iter().map(|l| para.clusters.get(l.c0).map_or(usize::MAX, |c| c.start)).collect(); + let hyph: Vec = para.hyph_after.iter().map(|k| para.clusters[*k as usize].end).collect(); + (starts, hyph) + }; + let (starts, hyph) = shape(&plain); + assert!(starts.len() > 5 && !hyph.is_empty(), "{starts:?} {hyph:?}"); + // Mapped to the fragmented paragraph: a line starts after the previous visible char (with the + // hidden one that follows it, which takes no room), a hyphen point is after its char. + let want_starts: Vec = starts.iter().map(|s| if *s == 0 { 0 } else { kept[s - 1] + 1 }).collect(); + let want_hyph: Vec = hyph.iter().map(|e| kept[e - 1] + 1).collect(); + assert_eq!(shape(&d), (want_starts, want_hyph)); +} + +#[test] +fn automatic_hyphenation_sees_only_the_text_laid_out() { + // Paragraph-text ends of the clusters after which a line may end with a hyphen. + let hyph_ends = |d: &Document, opts: &LayoutOptions| { + let l = layout(d, &mut LayoutCache::new(), opts); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + para.hyph_after.iter().map(|k| para.clusters[*k as usize].end).collect::>() + }; + let doc = |text: &str| { + let mut d = Document::from_text(text); + d.settings.auto_hyphenation = true; + d + }; + // (text, hidden bytes, deleted bytes, the text as laid out without them). + let cases = [ + // A hidden prefix: "table" must not break after its first letter as "acceptable" may. + ("acceptable", 0..5, 0..0, "table"), + // A hidden separator joins two words into one. + ("foot ball", 4..5, 0..0, "football"), + // Hidden text and a tracked deletion together. + ("accXeptYable", 7..8, 3..4, "acceptable"), + ]; + let hidden_opts = LayoutOptions { hide_deleted: true, ..Default::default() }; + for (text, hidden, deleted, visible) in cases { + let mut d = doc(text); + d.format_range(&Pos::body(0, hidden.start), &Pos::body(0, hidden.end), &|c| c.hidden = Some(true)).unwrap(); + if !deleted.is_empty() { + d.format_range(&Pos::body(0, deleted.start), &Pos::body(0, deleted.end), &|c| c.del = Some(0)).unwrap(); + } + // The same points as the visible text alone, at the matching paragraph offsets. + let kept: Vec = (0..text.len()).filter(|i| !hidden.contains(i) && !deleted.contains(i)).collect(); + let want: Vec = hyph_ends(&doc(visible), &hidden_opts).iter().map(|e| kept[e - 1] + 1).collect(); + assert_eq!(hyph_ends(&d, &hidden_opts), want, "{text:?} as {visible:?}"); + // Hidden text shown and markup shown: the whole text is hyphenated as before. + let all = LayoutOptions { show_hidden: true, ..Default::default() }; + assert_eq!(hyph_ends(&d, &all), hyph_ends(&doc(text), &all), "{text:?} shown"); + } +} + #[test] fn page_break_char_starts_new_page() { let mut d = Document::from_text("one"); @@ -329,11 +612,47 @@ fn wrapped_border_boxes_each_line() { assert_eq!(border_lines(&d).len(), 8); } +#[test] +fn runs_differing_only_in_link_or_decoration_keep_their_own_style() { + let mut d = Document::from_text("Alpha Beta Gamma Delta"); + let set = |d: &mut Document, a: usize, b: usize, f: &dyn Fn(&mut wordcraft_doc::props::CharProps)| { + d.format_range(&Pos::body(0, a), &Pos::body(0, b), f).unwrap(); + }; + set(&mut d, 0, 5, &|c| c.link = Some("https://a.example/".into())); + set(&mut d, 6, 10, &|c| c.link = Some("https://b.example/".into())); + set(&mut d, 11, 16, &|c| c.strike = Some(true)); + set(&mut d, 17, 22, &|c| c.double_strike = Some(true)); + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()); + let link_of = |word: &str| { + items.iter().find_map(|i| if let display::Draw::Glyphs { text, link, .. } = i { text.contains(word).then(|| link.clone()) } else { None }) + }; + assert_eq!(link_of("Alpha"), Some(Some("https://a.example/".into()))); + assert_eq!(link_of("Beta"), Some(Some("https://b.example/".into()))); + let strokes: Vec<_> = items.iter().filter_map(|i| if let display::Draw::Line { stroke, .. } = i { Some(*stroke) } else { None }).collect(); + assert!(strokes.contains(&display::Stroke::Solid) && strokes.contains(&display::Stroke::Double), "{strokes:?}"); + // Underline colour is per run too. + let mut d = Document::from_text("Red Blue"); + let red = wordcraft_doc::props::Rgb(0xFF, 0, 0); + let blue = wordcraft_doc::props::Rgb(0, 0, 0xFF); + for (a, b, c) in [(0, 3, red), (4, 8, blue)] { + d.format_range(&Pos::body(0, a), &Pos::body(0, b), &|p| { + p.underline = Some(wordcraft_doc::props::Underline::Single); + p.underline_color = Some(c); + }) + .unwrap(); + } + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()); + let colors: Vec<_> = items.iter().filter_map(|i| if let display::Draw::Line { color, .. } = i { Some(*color) } else { None }).collect(); + assert!(colors.contains(&red) && colors.contains(&blue), "{colors:?}"); +} + #[test] fn web_view_is_one_page() { let d = Document::from_text(&"text ".repeat(3000)); let mut c = LayoutCache::new(); - let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false, proofing: false }); + let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false, hide_deleted: false, proofing: false }); assert_eq!(l.pages.len(), 1); assert!(l.pages[0].h > 800.0); } @@ -412,6 +731,98 @@ fn footnotes_sit_at_page_bottom() { assert!(l.caret_on(&Pos { story: StoryRef::Part(eid), path: Path::top(0), off: 0 }, 0).is_some()); } +#[test] +fn deleted_note_anchors_take_no_number_and_print_no_note() { + use wordcraft_doc::para::NoteKind; + let mut d = Document::from_text("Alpha Beta Gamma"); + let mut ids = Vec::new(); + // Footnotes after "Alpha" and "Beta", an endnote after "Gamma" (inserted back to front). + for (off, kind, part) in [ + (16, NoteKind::Endnote, wordcraft_doc::PartKind::Endnote), + (10, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote), + (5, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote), + ] { + let id = d.add_part(part, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("A note.", Default::default()))]); + d.insert_object(&Pos::body(0, off), InlineObject::NoteRef { kind, id, custom: String::new() }, &Default::default()).unwrap(); + ids.push(id); + } + let [end_id, second, first] = ids[..] else { panic!() }; + // Track-delete the first footnote's anchor and the endnote's anchor. + let obj = wordcraft_doc::para::OBJ.len_utf8(); + d.format_range(&Pos::body(0, 5), &Pos::body(0, 5 + obj), &|c| c.del = Some(0)).unwrap(); + let end = d.para(StoryRef::Body, &Path::top(0)).unwrap().len(); + d.format_range(&Pos::body(0, end - obj), &Pos::body(0, end), &|c| c.del = Some(0)).unwrap(); + // The glyphs of each note mark in the body, and whether the endnote's text is placed. + let marks = |hide_deleted: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() }); + let items: Vec<&Placed> = l.pages.iter().flat_map(|p| p.items.iter()).collect(); + let mut marks = HashMap::new(); + for it in &items { + if let Placed::Lines { story: StoryRef::Body, para, .. } = it { + for (ci, id) in ¶.notes { + let c = ¶.clusters[*ci]; + marks.insert(*id, para.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect::>()); + } + } + } + let endnote = items.iter().any(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == end_id)); + (marks, endnote) + }; + // Markup: footnotes 1 and 2, and the endnote prints. + let (full, endnote) = marks(false); + assert!(full[&first] != full[&second] && endnote, "{full:?}"); + // Final text: the surviving footnote is number 1, and the deleted endnote anchor prints no note. + let (fin, endnote) = marks(true); + assert_eq!(fin.get(&second), full.get(&first), "the surviving footnote keeps number 2"); + assert!(!endnote, "the endnote of a deleted anchor is printed"); +} + +#[test] +fn hidden_note_anchors_keep_their_number_and_their_note() { + // Measured in Word (hidden text not printed): a hidden footnote or endnote reference mark still + // takes its number, so the visible footnote after it is 2, and both notes are still printed. + use wordcraft_doc::para::NoteKind; + let mut d = Document::from_text("Alpha Beta Gamma"); + let mut ids = Vec::new(); + for (off, kind, part) in [ + (16, NoteKind::Endnote, wordcraft_doc::PartKind::Endnote), + (10, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote), + (5, NoteKind::Footnote, wordcraft_doc::PartKind::Footnote), + ] { + let id = d.add_part(part, vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("A note.", Default::default()))]); + d.insert_object(&Pos::body(0, off), InlineObject::NoteRef { kind, id, custom: String::new() }, &Default::default()).unwrap(); + ids.push(id); + } + let [end_id, second, first] = ids[..] else { panic!() }; + // Hide the first footnote's anchor and the endnote's anchor. + let obj = wordcraft_doc::para::OBJ.len_utf8(); + d.format_range(&Pos::body(0, 5), &Pos::body(0, 5 + obj), &|c| c.hidden = Some(true)).unwrap(); + let end = d.para(StoryRef::Body, &Path::top(0)).unwrap().len(); + d.format_range(&Pos::body(0, end - obj), &Pos::body(0, end), &|c| c.hidden = Some(true)).unwrap(); + // The glyphs of each visible note mark in the body, and which notes' text is placed. + let marks = |show_hidden: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() }); + let items: Vec<&Placed> = l.pages.iter().flat_map(|p| p.items.iter()).collect(); + let mut marks = HashMap::new(); + for it in &items { + if let Placed::Lines { story: StoryRef::Body, para, .. } = it { + for (ci, id) in ¶.notes { + let c = ¶.clusters[*ci]; + marks.insert(*id, para.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect::>()); + } + } + } + let printed = |id: u32| items.iter().any(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == id)); + (marks, [first, second, end_id].map(printed)) + }; + let (shown, printed) = marks(true); + assert!(shown[&first] != shown[&second] && printed == [true; 3], "{shown:?} {printed:?}"); + // Not shown: the visible footnote is still number 2, and every note is printed. + let (fin, printed) = marks(false); + assert_eq!(fin.get(&second), shown.get(&second), "the visible footnote is renumbered"); + assert_eq!(printed, [true; 3], "a note whose reference is hidden is not printed"); +} + #[test] fn text_wraps_around_square_float() { let mut d = Document::from_text(&"Words flow around the picture here. ".repeat(30)); @@ -445,6 +856,100 @@ fn text_wraps_around_square_float() { assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. }))); } +#[test] +fn deleted_float_leaves_no_wrap_area_without_markup() { + // A square-wrapped shape anchored at the start of the paragraph, its anchor track-deleted. + let text = "Words flow around the picture here. ".repeat(30); + let mut d = Document::from_text(&text); + let float = wordcraft_doc::para::Float { + wrap: wordcraft_doc::para::Wrap::Square, + h_rel: wordcraft_doc::para::Anchor::Column, + v_rel: wordcraft_doc::para::Anchor::Paragraph, + x: 0.0, + y: 0.0, + dist: 9.0, + dist_top: 9.0, + dist_bottom: 9.0, + ..Default::default() + }; + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::Rectangle, + w: 144.0, + h: 100.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + }; + d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); + let obj = wordcraft_doc::para::OBJ.len_utf8(); + d.format_range(&Pos::body(0, 0), &Pos::body(0, obj), &|c| c.del = Some(0)).unwrap(); + // Where the first line starts, and whether the shape is placed. + let first = |hide_deleted: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() }); + let p = &l.pages[0]; + let Some(Placed::Lines { para, .. }) = p.items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + (para.lines[0].left, p.items.iter().any(|i| matches!(i, Placed::Shape { .. }))) + }; + // With markup the deleted shape is shown and the text flows beside it. + let (left, shown) = first(false); + assert!(left >= 144.0 && shown, "{left} {shown}"); + // Final text: no shape, and no gap where it was. + assert_eq!(first(true), (0.0, false)); +} + +#[test] +fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() { + // A square-wrapped shape whose anchor is hidden text, through a character style (so only the + // resolved formatting says so). Measured in Word: not printed, and the text runs full width. + let text = "Words flow around the picture here. ".repeat(30); + let mut d = Document::from_text(&text); + d.styles.upsert(wordcraft_doc::Style { + id: "Secret".into(), + name: "Secret".into(), + kind: wordcraft_doc::StyleKind::Character, + chr: wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() }, + ..Default::default() + }); + let float = wordcraft_doc::para::Float { + wrap: wordcraft_doc::para::Wrap::Square, + h_rel: wordcraft_doc::para::Anchor::Column, + v_rel: wordcraft_doc::para::Anchor::Paragraph, + x: 0.0, + y: 0.0, + dist: 9.0, + dist_top: 9.0, + dist_bottom: 9.0, + ..Default::default() + }; + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::Rectangle, + w: 144.0, + h: 100.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + }; + d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); + let obj = wordcraft_doc::para::OBJ.len_utf8(); + d.format_range(&Pos::body(0, 0), &Pos::body(0, obj), &|c| c.style = Some("Secret".into())).unwrap(); + // Where the first line starts, and whether the shape is placed. + let first = |show_hidden: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() }); + let p = &l.pages[0]; + let Some(Placed::Lines { para, .. }) = p.items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + (para.lines[0].left, p.items.iter().any(|i| matches!(i, Placed::Shape { .. }))) + }; + // Hidden text shown: the shape is placed and the text flows beside it. + let (left, shown) = first(true); + assert!(left >= 144.0 && shown, "{left} {shown}"); + // Not shown: no shape, and no gap where it was. + assert_eq!(first(false), (0.0, false)); +} + #[test] fn line_numbers_borders_text_boxes() { let mut d = Document::from_text("one\ntwo\nthree"); @@ -517,6 +1022,53 @@ fn tall_rows_split_across_pages() { assert!(l2.pages.len() >= 2); } +#[test] +fn repeated_header_is_followed_by_the_next_row_even_when_it_overflows() { + // A one-column table at the top of a letter page (body 648 pt), then an empty paragraph. Returns + // the table rows on each page, in order. + let rows_per_page = |t: Table| -> Vec> { + let mut d = Document::new(); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + l.pages + .iter() + .map(|p| p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect()) + .collect() + }; + // A header row of `header` points and body rows of `rows` points (at least), one line of text each. + let table = |header: f32, rows: &[f32]| { + let mut t = Table::new(rows.len() + 1, 1, 468.0); + t.rows[0].props.header = true; + for (row, h) in t.rows.iter_mut().zip(std::iter::once(&header).chain(rows)) { + row.props.height = Some(*h); + row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + } + t + }; + // Header and row fit together: one row per page, under the header. + assert_eq!(rows_per_page(table(100.0, &[400.0; 3])), vec![vec![0, 1], vec![0, 2], vec![0, 3]]); + // A 300 pt header and 400 pt rows never fit together. As in Word, the first row moves to a new + // page, goes under the repeated header and runs into the bottom margin; so does every later row. + assert_eq!(rows_per_page(table(300.0, &[400.0; 4])), vec![vec![0], vec![0, 1], vec![0, 2], vec![0, 3], vec![0, 4], vec![]]); + // A header that almost fills the page still repeats, with one row under it on each page. + assert_eq!(rows_per_page(table(640.0, &[100.0; 3])), vec![vec![0], vec![0, 1], vec![0, 2], vec![0, 3], vec![]]); + // A 20-row table under a 640 pt header: one page per row, never a runaway. + assert_eq!(rows_per_page(table(640.0, &[12.0; 19])).len(), 21); + // A row of text that is taller than the room under the header still splits across pages. + let mut t = table(100.0, &[12.0]); + let long = "Row text that keeps going and going so the cell grows taller than a page. ".repeat(120); + t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&long, Default::default()))]; + let pages = rows_per_page(t); + assert!(pages.len() >= 3, "{pages:?}"); + assert!(pages.iter().all(|p| p.first() == Some(&0) && p.contains(&1)), "{pages:?}"); + // An ordinary header still repeats on every page the table reaches. + let mut t = Table::new(80, 2, 468.0); + t.rows[0].props.header = true; + let pages = rows_per_page(t); + assert!(pages.len() >= 2, "{pages:?}"); + assert!(pages.iter().all(|p| p.first() == Some(&0)), "{pages:?}"); +} + #[test] fn drop_cap_indents_its_lines() { let mut p = @@ -1005,3 +1557,64 @@ fn no_line_break_right_after_a_slash() { } assert!(lines > 10, "{lines} lines"); } + +#[test] +fn unequal_formats_lay_out_in_linear_time() { + // Runs whose formats never compare equal (NaN spacing from a hostile file) each get a style; + // finding a run's style must not compare it against every earlier one. + let doc = |n: usize| { + let mut p = wordcraft_doc::Paragraph::with_text("", Default::default()); + p.text = "ab ".repeat(n); + p.runs = (0..n) + .map(|i| wordcraft_doc::Run { + len: 3, + props: wordcraft_doc::CharProps { spacing: Some(f32::NAN), bold: Some(i % 2 == 0), ..Default::default() }, + }) + .collect(); + let mut d = Document::from_text(""); + d.body = vec![wordcraft_doc::para_block(p)]; + d + }; + let (t1, tk, ratio) = layout_time_ratio(doc, 5_000, &LayoutOptions::default()); + eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "8x the runs took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear"); +} + +#[test] +fn hidden_float_in_a_table_cell_takes_no_room() { + use wordcraft_doc::para::{Anchor, Float, Wrap}; + // A square-wrapped shape anchored in hidden text inside a table cell, beside wrapping text. + let mut d = Document::from_text("Body text."); + let mut t = Table::new(1, 2, 468.0); + let mut cp = wordcraft_doc::Paragraph::with_text(&"Cell words wrap around it. ".repeat(8), Default::default()); + let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() }; + let hidden = wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() }; + cp.insert_object(0, rect_shape(60.0, 40.0, f), &hidden).unwrap(); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)]; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + // (shapes placed, the cell text's widest first-line indent) + let run = |show_hidden: bool| { + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() }); + let items = &l.pages[0].items; + let left = items + .iter() + .filter_map(|i| if let Placed::Lines { para, .. } = i { para.lines.first().map(|l| l.left) } else { None }) + .fold(0.0f32, f32::max); + (shapes(items).len(), left) + }; + let (n, left) = run(true); + assert!(n == 1 && left >= 60.0, "shown: placed and wrapped around ({n}, {left})"); + assert_eq!(run(false), (0, 0.0), "hidden: not placed, no wrap area"); +} + +#[test] +fn objects_left_out_through_the_table_style_formatting() { + // The table style's character formatting sits under the runs' own, as the cell is laid out. + let mut p = wordcraft_doc::Paragraph::with_text("ab", Default::default()); + p.insert_object(1, rect_shape(10.0, 10.0, Default::default()), &Default::default()).unwrap(); + let d = Document::from_text(""); + let hidden = wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() }; + assert!(!left_out_objects(&d, &p, None, false, false)(0)); + assert!(left_out_objects(&d, &p, Some(&hidden), false, false)(0)); + assert!(!left_out_objects(&d, &p, Some(&hidden), true, false)(0), "hidden text shown"); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b04d74a57..25285ac8f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -84,9 +84,10 @@ impl Default for PdfOptions { } } -/// Lay out `doc` and write it as PDF. +/// Lay out `doc` and write it as PDF. Without markup the layout is the final text: tracked +/// deletions are left out, as in Word's "No Markup" view. pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { - let lay = layout(doc, &mut LayoutCache::new(), &LayoutOptions::default()); + let lay = layout(doc, &mut LayoutCache::new(), &LayoutOptions { hide_deleted: !opts.include_markup, ..Default::default() }); export_layout(doc, &lay, opts) } @@ -534,8 +535,8 @@ impl Exporter<'_> { for it in &page.items { let draws = self.draws(page, it, &dopts); match it { - Placed::Lines { story, path, .. } => { - let idx = self.entry(*story, path); + Placed::Lines { story, path, para, .. } => { + let idx = self.entry(*story, path, para); for d in &draws { match d { Draw::Glyphs { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), @@ -571,14 +572,25 @@ impl Exporter<'_> { page_display(self.doc, &one, dopts) } + /// A heading's text for bookmarks and tags: what the pages show. That leaves out what its + /// layout `pl` left out (hidden text, resolved as laid out, so table formatting counts too) and, + /// without markup, tracked deletions. + fn title_text(&self, p: &wordcraft_doc::Paragraph, pl: &wordcraft_layout::para::ParaLayout) -> String { + let mut dropped = pl.left_out.clone(); + if !self.opts.include_markup { + dropped.extend(p.deleted_ranges()); + } + p.text_without(&dropped) + } + /// The structure entry for a paragraph (created on first sight, in reading order). - fn entry(&mut self, story: StoryRef, path: &DocPath) -> usize { + fn entry(&mut self, story: StoryRef, path: &DocPath, pl: &wordcraft_layout::para::ParaLayout) -> usize { if let Some(i) = self.tag_index.get(&(story, path.clone())) { return *i; } let kind: TagKind = match self.doc.para(story, path).map(|p| self.doc.styles.resolve_para(&p.props).outline_level) { Some(Some(l)) if l < 6 => { - let title = self.doc.para(story, path).map(|p| p.plain_text().trim().chars().take(200).collect::()); + let title = self.doc.para(story, path).map(|p| self.title_text(p, pl).trim().chars().take(200).collect::()); Tag::Hn(NonZeroU16::new(u16::from(l) + 1).unwrap_or(NonZeroU16::MIN), title).into() } _ => Tag::P.into(), @@ -933,10 +945,10 @@ impl Exporter<'_> { for (out, pi) in pages.iter().enumerate() { let Some(page) = self.lay.pages.get(*pi) else { continue }; for it in &page.items { - let Placed::Lines { story: StoryRef::Body, path, l0: 0, x, y, .. } = it else { continue }; + let Placed::Lines { story: StoryRef::Body, path, para, l0: 0, x, y, .. } = it else { continue }; let Some(p) = self.doc.para(StoryRef::Body, path) else { continue }; let Some(level) = self.doc.styles.resolve_para(&p.props).outline_level else { continue }; - let title: String = p.plain_text().split_whitespace().collect::>().join(" ").chars().take(200).collect(); + let title: String = self.title_text(p, para).split_whitespace().collect::>().join(" ").chars().take(200).collect(); if title.is_empty() { continue; } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2e6a64818..cde73f8e4 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -208,6 +208,167 @@ fn hostile_documents_export() { assert!(export(&Document::new(), &PdfOptions::default()).unwrap().starts_with(b"%PDF")); } +#[test] +fn tracked_deletions_are_left_out_without_markup() { + use wordcraft_doc::{Revision, RevisionKind}; + let mut d = Document::new(); + d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new() }); + d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new() }); + let mut p = Paragraph::with_text("Keep ", CharProps::default()); + let n = p.len(); + p.insert_text(n, "DELETEDTEXT ", &CharProps { del: Some(0), ..Default::default() }).unwrap(); + let n = p.len(); + p.insert_text(n, "INSERTED", &CharProps { ins: Some(1), ..Default::default() }).unwrap(); + d.body = vec![para_block(p)]; + // The default export shows the final text, as Word's "No Markup" does. + let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat()); + assert_eq!(text, "Keep INSERTED"); + // With markup the deletion is still there (struck through). + let text = squash(&extract_text(&export(&d, &PdfOptions { include_markup: true, ..Default::default() }).unwrap()).concat()); + assert_eq!(text, "Keep DELETEDTEXT INSERTED"); +} + +#[test] +fn deleted_heading_text_stays_out_of_bookmarks_and_tags() { + use wordcraft_doc::{Revision, RevisionKind}; + let mut d = Document::new(); + d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new() }); + let del = CharProps { del: Some(0), ..Default::default() }; + // "Intro SECRET Part" with "SECRET " deleted, then a heading deleted as a whole. + let mut h = Paragraph::with_text("Intro ", CharProps::default()).styled("Heading1"); + let n = h.len(); + h.insert_text(n, "SECRET ", &del).unwrap(); + let n = h.len(); + h.insert_text(n, "Part", &CharProps::default()).unwrap(); + let gone = Paragraph::with_text("GONE", del.clone()).styled("Heading1"); + d.body = vec![para_block(h), para_block(Paragraph::with_text("Body text", CharProps::default())), para_block(gone)]; + let raw = |opts: PdfOptions| String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..opts }).unwrap()).into_owned(); + // Bookmarks alone (untagged), then bookmarks and heading tags: the final text only. + for tagged in [false, true] { + let pdf = raw(PdfOptions { tagged, ..Default::default() }); + assert!(!pdf.contains("SECRET") && !pdf.contains("GONE"), "tagged {tagged}: deleted heading text in the PDF"); + assert!(pdf.contains("/Outlines") && pdf.contains("Intro Part"), "tagged {tagged}: no bookmark for the heading"); + } + // With markup the deleted text is shown, and so it is in the bookmarks. + let pdf = raw(PdfOptions { include_markup: true, ..Default::default() }); + assert!(pdf.contains("Intro SECRET Part") && pdf.contains("GONE")); +} + +#[test] +fn hidden_heading_text_stays_out_of_bookmarks_and_tags() { + let mut d = Document::new(); + // Hidden text both directly and through a character style. + d.styles.upsert(wordcraft_doc::Style { + id: "Secret".into(), + name: "Secret".into(), + kind: wordcraft_doc::StyleKind::Character, + chr: CharProps { hidden: Some(true), ..Default::default() }, + ..Default::default() + }); + let hidden = CharProps { hidden: Some(true), ..Default::default() }; + // "Intro SECRET Part" with "SECRET " hidden, a heading hidden as a whole, and one hidden by style. + let mut h = Paragraph::with_text("Intro ", CharProps::default()).styled("Heading1"); + let n = h.len(); + h.insert_text(n, "SECRET ", &hidden).unwrap(); + let n = h.len(); + h.insert_text(n, "Part", &CharProps::default()).unwrap(); + let gone = Paragraph::with_text("GONE", hidden.clone()).styled("Heading1"); + let styled = Paragraph::with_text("STYLED", CharProps { style: Some("Secret".into()), ..Default::default() }).styled("Heading1"); + d.body = vec![ + para_block(h), + para_block(Paragraph::with_text("Body text", CharProps::default())), + para_block(gone), + para_block(styled), + para_block(Paragraph::with_text("More text", CharProps::default())), + ]; + let raw = |opts: PdfOptions| String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..opts }).unwrap()).into_owned(); + // The PDF never prints hidden text, with or without markup, so neither do bookmarks or tags. + for (tagged, include_markup) in [(false, false), (true, false), (true, true)] { + let pdf = raw(PdfOptions { tagged, include_markup, ..Default::default() }); + let what = format!("tagged {tagged} markup {include_markup}"); + assert!(!pdf.contains("SECRET") && !pdf.contains("GONE") && !pdf.contains("STYLED"), "{what}: hidden heading text in the PDF"); + assert!(pdf.contains("/Outlines") && pdf.contains("Intro Part"), "{what}: no bookmark for the heading"); + } +} + +#[test] +fn long_fragmented_hidden_headings_export_in_linear_time() { + // Headings of n chars in one-char runs: all hidden with bold alternating, and hidden and visible + // alternating. Layout and the bookmark/tag titles once scanned every left-out range for every + // char, quadratic in n. Machine-independent: the time for 4n against n, linear ~4, quadratic ~16. + let doc = |n: usize| { + let heading = |visible_every_other: bool| { + let mut h = Paragraph::with_text(&"abcdefg ".repeat(n / 8), CharProps::default()).styled("Heading1"); + h.runs = (0..n) + .map(|i| { + let hidden = !(visible_every_other && i % 2 == 1); + wordcraft_doc::Run { len: 1, props: CharProps { hidden: Some(hidden), bold: Some(i % 4 < 2), ..Default::default() } } + }) + .collect(); + para_block(h) + }; + let mut d = Document::new(); + d.body = vec![heading(false), heading(true), para_block(Paragraph::with_text("After", CharProps::default()))]; + d + }; + let opts = PdfOptions { compress: false, ..Default::default() }; + // The fastest of a few runs, after a warm-up, so one slow run on a busy machine counts less. + let fastest = |d: &Document| { + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + export(d, &opts).unwrap(); + t.elapsed().as_secs_f64() + }) + .fold(f64::INFINITY, f64::min) + }; + let n = 25_000; + let (small, big) = (doc(n), doc(4 * n)); + export(&small, &opts).unwrap(); + let (t1, t4) = (fastest(&small), fastest(&big)); + let ratio = t4 / t1.max(1e-9); + eprintln!("t(n) {t1:.3} s, t(4n) {t4:.3} s, ratio {ratio:.1}"); + assert!(ratio < 8.0, "4x the text took {ratio:.1}x the time ({t1:.3} s → {t4:.3} s): not linear"); + let pdf = String::from_utf8_lossy(&export(&big, &opts).unwrap()).into_owned(); + // The all-hidden heading has no bookmark; the other is titled with its visible chars only. + assert!(!pdf.contains("abcd"), "hidden heading text in a title"); + assert!(pdf.contains("bdf bdf"), "no title for the half-visible heading"); +} + +#[test] +fn table_style_hidden_heading_text_stays_out_of_bookmarks_and_tags() { + // Hidden by the table style's header row, so only the layout's resolution (table conditional + // formatting under the runs' own) says the text is hidden. + let mut d = Document::new(); + d.styles.upsert(wordcraft_doc::Style { + id: "SecretHeader".into(), + name: "Secret Header".into(), + kind: wordcraft_doc::StyleKind::Table, + table: Some(wordcraft_doc::styles::TableStyleParts { + header_chr: CharProps { hidden: Some(true), ..Default::default() }, + ..Default::default() + }), + ..Default::default() + }); + let mut t = Table::new(2, 2, 400.0); + t.props.style = Some("SecretHeader".into()); + // Header row: a heading hidden as a whole, and "Shown SECRET" where "Shown " is unhidden directly. + let mut partly = Paragraph::with_text("Shown ", CharProps { hidden: Some(false), ..Default::default() }).styled("Heading1"); + let n = partly.len(); + partly.insert_text(n, "SECRET", &CharProps::default()).unwrap(); + t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("GONE", CharProps::default()).styled("Heading1"))]; + t.rows[0].cells[1].blocks = vec![para_block(partly)]; + t.rows[1].cells[0].blocks = vec![para_block(Paragraph::with_text("Body cell", CharProps::default()))]; + d.body = vec![para_block(Paragraph::with_text("Before", CharProps::default())), Arc::new(Block::Table(t))]; + let raw = |opts: PdfOptions| String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..opts }).unwrap()).into_owned(); + for (tagged, include_markup) in [(false, false), (true, false), (true, true)] { + let pdf = raw(PdfOptions { tagged, include_markup, ..Default::default() }); + let what = format!("tagged {tagged} markup {include_markup}"); + assert!(!pdf.contains("SECRET") && !pdf.contains("GONE"), "{what}: hidden table heading text in the PDF"); + assert!(pdf.contains("/Outlines") && pdf.contains("Shown"), "{what}: no bookmark for the visible part"); + } +} + #[test] fn text_mapping_handles_ligatures_and_extras() { let d = Document::from_text("office affine"); diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 735417158..358502e9e 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -153,41 +153,55 @@ pub fn hyphen_points(word: &str, lim: &Limits) -> Vec { } let min_before = lim.after_first.max(1); let min_after = lim.before_last.max(1); - // Run bounds of each point's letter run (trimmed of apostrophes). - let run_of = |p: usize| { - let mut a = p; - while a > 0 && is_word_char(chars[a - 1]) { - a -= 1; + // Counted once, so each point's checks take constant time however long the word: letters and + // uppercase letters before each char, and the letter run (trimmed of apostrophes) of each char. + let (mut letters, mut upper) = (Vec::with_capacity(chars.len() + 1), Vec::with_capacity(chars.len() + 1)); + let (mut l, mut u) = (0usize, 0usize); + for c in &chars { + letters.push(l); + upper.push(u); + if c.is_alphabetic() { + l += 1; + u += usize::from(c.is_uppercase()); } - let mut b = p; - while b < chars.len() && is_word_char(chars[b]) { - b += 1; + } + letters.push(l); + upper.push(u); + let mut run_of: Vec<(usize, usize)> = vec![(0, 0); chars.len()]; + let mut i = 0; + while i < chars.len() { + let mut j = i; + while chars.get(j).is_some_and(|c| is_word_char(*c)) { + j += 1; } - while a < b && is_apostrophe(chars[a]) { + let (mut a, mut b) = (i, j); + while a < b && chars.get(a).is_some_and(|c| is_apostrophe(*c)) { a += 1; } - while b > a && is_apostrophe(chars[b - 1]) { + while b > a && chars.get(b - 1).is_some_and(|c| is_apostrophe(*c)) { b -= 1; } - (a, b) - }; + for r in run_of.get_mut(i..j).into_iter().flatten() { + *r = (a, b); + } + i = j.max(i + 1); + } + let count = |v: &[usize], a: usize, b: usize| v.get(b).zip(v.get(a)).map_or(0, |(y, x)| y.saturating_sub(*x)); pts.into_iter() .filter(|&p| { - let (a, b) = run_of(p); - let run = &chars[a..b]; - let letters = run.iter().filter(|c| c.is_alphabetic()).count(); - if letters < lim.min_word.max(2) { + // A point lies between two letters of one run; the char after it gives the run. + let Some(&(a, b)) = run_of.get(p) else { return false }; + let n = count(&letters, a, b); + if n < lim.min_word.max(2) { return false; } - if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) { + if n > 1 && count(&upper, a, b) == n { return false; } - if !lim.capitalized && run[0].is_uppercase() { + if !lim.capitalized && chars.get(a).is_some_and(|c| c.is_uppercase()) { return false; } - let before = chars[a..p].iter().filter(|c| c.is_alphabetic()).count(); - let after = chars[p..b].iter().filter(|c| c.is_alphabetic()).count(); - before >= min_before && after >= min_after + count(&letters, a, p) >= min_before && count(&letters, p, b) >= min_after }) .collect() } @@ -230,8 +244,33 @@ fn heuristic_points(c: &[char]) -> Vec { return vec![]; } let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + // Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count. The part + // after `i` is `c[i..end]`, `end` before that ending; computed once, so each point is O(1). + let end = |i: usize| { + if n - i >= 2 && (c.ends_with(&['e', 'd']) || c.ends_with(&['e', 's'])) { + n - 2 + } else if c.ends_with(&['e']) { + n - 1 + } else { + n + } + }; + // UTF-8 bytes before each char, and the first vowel at or after each char. + let mut bytes = Vec::with_capacity(n + 1); + let mut acc = 0usize; + for ch in c { + bytes.push(acc); + acc += ch.len_utf8(); + } + bytes.push(acc); + let mut next_vowel = vec![n; n + 1]; + for i in (0..n).rev() { + next_vowel[i] = if v[i] { i } else { next_vowel[i + 1] }; + } let mut out = vec![]; + let mut vowel_before = false; for i in 1..n { + vowel_before |= v[i - 1]; let ok = if v[i - 1] && !v[i] { (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) } else if !v[i - 1] && !v[i] { @@ -239,10 +278,8 @@ fn heuristic_points(c: &[char]) -> Vec { } else { false }; - // Both parts need a sounded vowel: a trailing silent `e`, `ed` or `es` doesn't count. - let right: String = c[i..].iter().collect(); - let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right); - if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) { + let e = end(i); + if ok && next_vowel[i] < e && bytes[e] - bytes[i] > 1 && vowel_before { out.push(i); } } @@ -319,6 +356,172 @@ mod tests { assert!(hyphen_points("typography", &lim).is_empty()); } + /// The fastest of three timings of `f`, after a warm-up. + fn fastest(f: impl Fn()) -> f64 { + f(); + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() + }) + .fold(f64::INFINITY, f64::min) + } + + /// `hyphen_points`' limit checks as first written (a rescan of the letter run per point): the + /// reference for the linear version. + fn hyphen_points_ref(word: &str, lim: &Limits) -> Vec { + let chars: Vec = word.chars().collect(); + let (pts, _) = word_breaks(word); + let min_before = lim.after_first.max(1); + let min_after = lim.before_last.max(1); + let run_of = |p: usize| { + let mut a = p; + while a > 0 && is_word_char(chars[a - 1]) { + a -= 1; + } + let mut b = p; + while b < chars.len() && is_word_char(chars[b]) { + b += 1; + } + while a < b && is_apostrophe(chars[a]) { + a += 1; + } + while b > a && is_apostrophe(chars[b - 1]) { + b -= 1; + } + (a, b) + }; + pts.into_iter() + .filter(|&p| { + let (a, b) = run_of(p); + let run = &chars[a..b]; + let letters = run.iter().filter(|c| c.is_alphabetic()).count(); + if letters < lim.min_word.max(2) { + return false; + } + if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) { + return false; + } + if !lim.capitalized && run[0].is_uppercase() { + return false; + } + let before = chars[a..p].iter().filter(|c| c.is_alphabetic()).count(); + let after = chars[p..b].iter().filter(|c| c.is_alphabetic()).count(); + before >= min_before && after >= min_after + }) + .collect() + } + + /// `heuristic_points` as first written (the right part rebuilt per point): the reference. + fn heuristic_ref(c: &[char]) -> Vec { + let n = c.len(); + if n < 4 || !c.iter().all(|c| c.is_alphabetic()) { + return vec![]; + } + let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + let mut out = vec![]; + for i in 1..n { + let ok = if v[i - 1] && !v[i] { + (i + 1 < n && v[i + 1]) || (i + 2 < n && onset_pair(c[i], c[i + 1]) && v[i + 2]) + } else if !v[i - 1] && !v[i] { + i >= 2 && v[i - 2] && i + 1 < n && v[i + 1] && !onset_pair(c[i - 1], c[i]) && c[i - 1] != c[i] + || (c[i - 1] == c[i] && i >= 2 && v[i - 2]) + } else { + false + }; + let right: String = c[i..].iter().collect(); + let core = right.strip_suffix("ed").or_else(|| right.strip_suffix("es")).or_else(|| right.strip_suffix('e')).unwrap_or(&right); + if ok && core.chars().any(is_vowel) && core.len() > 1 && v[..i].iter().any(|x| *x) { + out.push(i); + } + } + out + } + + #[test] + fn linear_limits_and_heuristic_match_the_reference() { + let long = "hyphenation".repeat(40); + let words = [ + "", + "x", + "''", + "a'b'c", + "cat", + "happen", + "hyphenation", + "Typography's", + "TYPOGRAPHY", + "Typography", + "typography’s", + "'typography'", + "“typography”,", + "typography-composition", + "don't", + "rock'n'roll", + "naïveté", + "crème-brûlée", + "Ünïcödé", + "ÉTUDE", + "strengths", + "ee", + "bed", + "hopped", + "rhythmed", + "aeiouy", + "xxyyzz", + "bookkeeper", + "ʼtwas", + "mid-1990s", + "twenty-one", + "école", + "Ålesund", + "smørrebrød", + "hopes", + "hoped", + "taed", + "teeed", + &long, + ]; + for w in words { + for min_word in [2, 5, 7] { + for (after_first, before_last) in [(1, 1), (2, 2), (3, 2), (2, 4)] { + for capitalized in [true, false] { + let lim = Limits { min_word, after_first, before_last, capitalized }; + assert_eq!(hyphen_points(w, &lim), hyphen_points_ref(w, &lim), "{w:?} {lim:?}"); + } + } + } + let c: Vec = w.to_lowercase().chars().collect(); + assert_eq!(heuristic_points(&c), heuristic_ref(&c), "{w:?}"); + } + } + + #[test] + fn hyphenating_a_long_word_is_linear() { + // A word of n letters against one of 4n: linear ~4x the time, the old rescans ~16x. One in + // the pattern alphabet, one outside it (the syllable heuristic). + let pattern = |k: usize| "hyphenation".repeat(k); + let heuristic = |k: usize| "smørrebrød".repeat(k); + assert_eq!(word_breaks(&heuristic(2)).1, Source::Heuristic); + // Each timing repeats the call so the shorter one is milliseconds, well above timer noise. + let ratios: Vec<(String, f64)> = [(pattern as fn(usize) -> String, 20), (heuristic, 40)] + .into_iter() + .map(|(word, repeat)| { + let (small, big) = (word(400), word(1600)); + let time = |w: &str| fastest(|| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default())))); + let (t1, t4) = (time(&small), time(&big)); + let ratio = t4 / t1.max(1e-9); + let what = format!("{}…: t(n) {t1:.4} s, t(4n) {t4:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::()); + eprintln!("{what}"); + (what, ratio) + }) + .collect(); + for (what, ratio) in ratios { + assert!(ratio < 8.0, "4x the letters, not linear: {what}"); + } + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect();