From 6c152055b90ab22d5201a458a994d0affa468c84 Mon Sep 17 00:00:00 2001 From: AiBizzApps <133780730+goldsbd@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:35:35 +0700 Subject: [PATCH] Hidden text: no line breaks, hyphens, wrap areas or bookmark text from text that isn't shown (builds on #152) (#173) * Tables: stop repeating a header row that leaves no room for the next row A repeating header row plus a row that could not fit below it on an empty page made place_table loop: after each page break the header was placed again, so the page was never "at top" when the row was retried, split_row had nothing to split, and the loop ran its 1,000 iterations per row. A 20-row table with a 640 pt header came out as 19,002 pages. After a page break the header now repeats only when the row (or its first split) still fits below it; otherwise the row starts at the top of the page, as Word does when a header row would fill the page. Co-Authored-By: Claude Opus 5.5 * Layout: give runs with different links or decorations their own style Builder::style memoised StyleRuns by a key that folded strike, double strike and "has a link" into one boolean and left out the underline colour and baseline position. The display list reads all of these from the shared StyleRun, so in a paragraph of like-formatted links every link pointed at the first one's URL in the PDF (and an external link after an internal one became a bookmark jump), double strike drew as single strike after a struck run, and underline colours were copied. The key is now the whole resolved format, so runs share a StyleRun only when nothing the display list reads differs. Co-Authored-By: Claude Opus 5.5 * PDF and No Markup: leave tracked deletions out of the final text PDF export defaults to no markup, but it laid the document out with every deleted run in place and only switched off the strike and colour, so the PDF printed deleted text as ordinary text ("Keep DELETEDTEXT INSERTED"): neither the original nor the final document, and a leak of text the author removed. The No Markup view did the same on screen. A new LayoutOptions::hide_deleted lays deleted runs out like hidden text (zero-width markers, so offsets, carets and hit testing keep working). PDF export sets it whenever markup is off, the session layout sets it when markup is hidden, and the markup toggles relayout as Show/Hide marks does. The display list also never draws a deletion as plain text when markup is off, whatever layout it is given. Co-Authored-By: Claude Opus 5.5 * Tables: repeat the header on every page and put the next row under it, as Word does The earlier fix for the runaway loop dropped the repeated header row on any page where the header and the next row did not fit together. Word never does that: measured on Word for Mac, it moves the row to a new page, repeats the header there and places the row under it even when the row then runs into the bottom margin (a 300 pt header with 400 pt rows gives one header + row per page; a 640 pt header with 100 pt rows does the same). place_table now remembers when the page holds only the repeated header rows. A row that cannot split is placed there anyway instead of breaking the page again, which is both Word's behaviour and what keeps the loop from spinning: every pass now places something. Rows of text taller than the room under the header still split across pages. Co-Authored-By: Claude Opus 5.5 * PDF: keep tracked deletions out of bookmarks and heading tags Without markup the PDF pages show the final text, but bookmark titles and the titles of heading structure tags were still built from plain_text(), which includes deleted runs. A heading "Intro SECRET Part" with "SECRET " deleted printed as "Intro Part" yet bookmarked as "Intro SECRET Part", and a fully deleted heading still got a bookmark: text the author removed leaked into the outline and the tag tree. Paragraph::final_text() gives the text with deletions left out, and the exporter titles headings with it unless markup is included. A heading whose text is all deleted has an empty title and so no bookmark. Co-Authored-By: Claude Opus 5.5 * No Markup: deleted note references take no number and print no endnote With deletions hidden, a tracked-deleted footnote or endnote reference disappears from the text, but note_numbers() still counted it. Deleting the first of two footnote references left the surviving one numbered 2, and a deleted endnote reference still printed its endnote after the last paragraph, because the endnote list is taken from the same map. note_numbers() now skips references inside tracked deletions when the layout hides them, so the remaining notes number from 1 and endnotes whose reference was deleted are not emitted. With markup shown nothing changes. Co-Authored-By: Claude Opus 5.5 * No Markup: a deleted floating object leaves no wrap area behind With deletions hidden, a tracked-deleted floating picture or shape was not drawn, but place_para had already added its wrap exclusion from p.objects without looking at the anchor, so the final text still flowed around an invisible object (indented beside a square-wrapped shape). Floating objects whose anchor is a tracked deletion are now skipped when the layout hides deletions; deleted_objects() is shared with note numbering, which needs the same test. With markup shown nothing changes. Co-Authored-By: Claude Opus 5.5 * No Markup: a deleted soft hyphen no longer breaks the word with a hyphen With deletions hidden, a tracked-deleted soft hyphen became a zero-width marker, but hyphenation_points() reads the paragraph's raw text and accepted marker clusters, so the line could still break there and draw "ab-" in a narrow column although the final text has no soft hyphen. When the layout hides deletions, clusters inside a tracked deletion are no longer hyphenation points (soft hyphens or dictionary points alike). With markup shown nothing changes. Co-Authored-By: Claude Opus 5.5 * No Markup: tracked deletions add no line-break opportunities Break opportunities came from running UAX #14 over the paragraph's raw text, deleted runs included. With deletions hidden, a deleted soft hyphen or space still let the line break there: "xx abSHYcdef" in a narrow column came out as "xx ab" / "cdef" instead of moving the word "abcdef" to the next line whole. When the layout hides deletions, the opportunities are computed over the text without them and mapped back to offsets in the paragraph. With markup shown, or with no deletions, the computation is unchanged. Co-Authored-By: Claude Opus 5.5 * Hidden text: no hyphenation points or line breaks in text that is not shown Hidden text that is not shown (the default, and always in print and PDF) is laid out as zero-width markers, but break opportunities came from UAX #14 over the paragraph's raw text and hyphenation_points() accepted marker clusters, so a hidden space or soft hyphen still let the line break there: "xx abcdef" in a narrow column came out "xx ab-" / "cdef". Measured in Word for Mac, the hidden soft hyphen and the hidden space are no place to break: "xx" / "abcdef". left_out() is now the one test for text the layout leaves out (hidden text not shown, tracked deletions without markup). The run loop records the ranges it leaves out, and both the break opportunities and the hyphenation points come from that, so deletions behave exactly as before. Co-Authored-By: Claude Opus 5.5 * Hidden text: a floating object anchored in it leaves no wrap area behind With hidden text not shown, a floating picture or shape anchored in a hidden run was not drawn (the run is a zero-width marker, so no object cluster places it), but place_para had already added its wrap exclusion, so the text still flowed around an invisible object. Measured in Word for Mac: a square-wrapped picture anchored in hidden text is not printed and the text runs the full width. deleted_objects() becomes left_out_objects(), which resolves each anchor's formatting through the styles (hidden text often comes from a character style) and applies the same left_out() test as the paragraph layout. Note numbering keeps counting hidden reference marks, as Word does, so it asks only about deletions; its behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 * Hidden text: print the footnote of a hidden reference mark, as Word does A footnote whose reference mark is hidden text was numbered but never printed: the hidden run became zero-width markers without recording the note, so place_para never put the footnote at the foot of the page. Measured in Word for Mac with hidden text not printed, a hidden footnote or endnote reference still takes its number (the next visible footnote is 2) and both notes are printed. Endnotes already were, and the numbering already matched. A hidden reference mark now records its note like a visible one, so the footnote goes to the foot of the page holding the line it sits on. A deleted reference in the final text still records nothing, as before. Co-Authored-By: Claude Opus 5.5 * PDF: keep hidden text out of bookmarks and heading tags The PDF never prints hidden text (its layout does not show it), but the bookmark and heading-tag titles were built from final_text(), which drops tracked deletions only. A heading "Intro SECRET Part" with "SECRET " hidden printed as "Intro Part" yet bookmarked as "Intro SECRET Part", and a heading that is all hidden text still got a bookmark for a heading nobody can see. Paragraph::text_where() generalises final_text() to any per-run test, and the exporter titles headings with the runs that are neither hidden (resolved through the styles, since a character style can hide text) nor, without markup, deleted. A heading that is all hidden has an empty title and so no bookmark. Word's PDF export from AppleScript writes no outline, so there is no Word answer to match; this follows what the pages show, as for deletions. Co-Authored-By: Claude Opus 5.5 * PDF: titles leave out what the layout left out, table formatting included Bookmark and heading-tag titles resolved hidden text from the paragraph and run formatting alone, but the layout also applies the table style's conditional formatting (header row, first column, total row) beneath the runs. A Heading 1 in a table header row whose style hides it printed nothing yet still got a bookmark, and a partly hidden heading leaked the hidden part into its tag. ParaLayout now records the byte ranges it laid out as nothing (left_out), resolved exactly as laid out, and the exporter titles a heading from the text outside them (plus tracked deletions without markup, as before), so the titles cannot disagree with the pages. text_where() gives way to text_without() over byte ranges; final_text() is unchanged. Co-Authored-By: Claude Opus 5.5 * Hyphenation: hyphenate words as laid out, without hidden or deleted text Automatic hyphenation looked up the words of the paragraph's raw text and only dropped points that fell inside text the layout leaves out. Words and the minimum-letters limits still came from the raw text: with "accep" hidden in "acceptable", the visible "table" could break as "t-able" (the point after "accept"), and a hidden space split "football" into two words, so the visible "football" never hyphenated. Soft hyphens and dictionary points are now found in the same text as laid out that the UAX #14 break opportunities already use, and mapped back to paragraph offsets. With hidden text shown and markup on, that text is the paragraph's own and nothing changes. Co-Authored-By: Claude Opus 5.5 * Layout, PDF: skip left-out text in one pass, not a scan per char Building the text as laid out tested every char against every left-out range, and Paragraph::text_without did the same for PDF titles. Hidden text in many runs (formatting that alternates, a common result of imports) made both quadratic: a 100,000-char hidden heading in one-char runs took 13.9 s to convert with the release CLI, 400,000 chars minutes. The layout now merges adjacent left-out runs as it records them and walks the ordered ranges with a cursor; text_without sorts its ranges by start (they may come in any order and overlap) and walks them the same way. Both are linear: 0.20 s for that heading, 0.81 s at 400,000 chars, with the same output. No left-out text still means no filtering at all. Co-Authored-By: Claude Opus 5.5 * Hyphenation: check the limits of each point in constant time hyphen_points() found each candidate point's letter run by scanning out from the point and counted the letters on each side by rescanning the run, and the syllable heuristic rebuilt the rest of the word as a string for every position. Both are quadratic in the word's length. Ordinary words are short, but with hidden separators the layout now hyphenates the visible word, so "hyphenation " repeated 8,192 times with every space hidden is one 90,112-letter word: 2.0 s to convert with the release CLI. Letter and uppercase counts before each char, the letter run of each char and the heuristic's next vowel and byte offsets are now computed once, so every point is checked in constant time: that document converts in 0.09 s. The output is unchanged: the old functions stay in the tests as the reference, compared over awkward words and every combination of limits, and a hyphenated document's PDF text is identical. No maximum word length is imposed: the work is linear, so a cap would only change output. Co-Authored-By: Claude Opus 5.5 * Layout: find the formatting of every object anchor in one walk left_out_objects() asked props_of_char() for each floating object's anchor, and props_of_char() scans the runs from the start, so a paragraph with many anchors in their own runs cost a scan per anchor, quadratic, and paid it on every layout because it runs before the paragraph cache is consulted: 40,000 hidden anchors took 0.69 s, against 0.05 s for 10,000. The runs and the object offsets are both in order, so one walk now finds each anchor's run, resolving a run's formatting once; past the last run it is the paragraph mark, as props_of_char() gives. With hidden text shown and markup on nothing can be left out and no work is done at all. Co-Authored-By: Claude Opus 5.5 * Layout: find a run's style without formatting it as a string #152 keyed each run's style by its whole format's Debug string, built for every shaped piece; on a long document with many runs that doubled layout time (3000 paragraphs x 40 linked runs: 12.7 s vs 6.4 s for the old key). Key by a hash of the commonly varying fields and confirm by comparing formats: 3.8 s. A capped bucket scan keeps formats that never compare equal (NaN) linear. Co-Authored-By: Claude Opus 5.5 * Layout: floats hidden in table cells take no room, table style included Test the layout_box path the merge extended (a float anchored in hidden text in a table cell is not placed and leaves no wrap area), and resolve the anchors' formatting with the table style's character formatting under the runs' own, as the cell's text is laid out. Co-Authored-By: Claude Opus 5.5 * Tests: linear-time checks compare 8x the input against a 24x limit At 4x with an 8x limit a busy machine could push a linear layout over the line (8.8x seen once in a full workspace run); at 8x a linear layout takes about 8x and a quadratic one about 64x, so 24x stays far from both. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/para.rs | 62 ++++ crates/engine/src/cmd/review.rs | 2 + crates/engine/src/cmd/tools.rs | 1 + crates/engine/src/session.rs | 10 +- crates/engine/src/tests.rs | 21 ++ crates/layout/src/display.rs | 4 + crates/layout/src/lib.rs | 77 +++- crates/layout/src/para.rs | 120 +++++-- crates/layout/src/tests.rs | 615 +++++++++++++++++++++++++++++++- crates/pdf/src/lib.rs | 28 +- crates/pdf/src/tests.rs | 161 +++++++++ crates/proof/src/hyphen.rs | 253 +++++++++++-- 12 files changed, 1287 insertions(+), 67 deletions(-) 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();