Files
wordcraft/crates/layout/src/tests/styleref.rs
T
9b6e9564a2 Lists: a numFmt none level shows its literal text, so RESPONSE: labels the paragraph (#4985) (#5055)
* #4985: a numFmt none list level shows its literal text

Word labels a paragraph whose level has numFmt none and lvlText RESPONSE:
with RESPONSE:. Counters::next_label returned no label for any none level;
now only a none level of placeholders shows nothing. Its own %n stays
empty, and it still doesn't count. Numbering::shows_label agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4985: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4985: a "none" list level counts, and only its own number is empty

Taken from MeHail-02's numbering-literal-none branch (46405e66), checked in Word
for Windows: a none level is an ordinary level whose own number reads as
nothing. It counts and restarts the levels below it, stays empty in legal
numbering, and an empty label still tabs the text to the indent unless it
already ends there (the Chapter 1 list's Heading 2 stays at the margin).

Co-Authored-By: MeHail <mihailsmetanin15@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4985: STYLEREF in full context keeps a none-level heading's parent

Review: a none level now has a label, so its headings reach the style index,
which leaves the none level out of the levels it keeps; \w and \r took the last
kept level for the heading's own and dropped the parent. Levels now carry their
index, and the levels above are picked by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4985: STYLEREF number switches on a none-level heading

Review: in full context an empty label of its own added a trailing period (3.),
and the suppress switch gave a none level's label (Note), which holds no number,
back whole. An empty own part is left out, and a none level's label with no
number is suppressed to nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4985: STYLEREF \s and \t on none-level labels

Review: \s took a none level's label (Note) for a chapter number; the heading's
count stands in, as for an unnumbered heading. \t matched any ancestor's number
inside a word (the a of Chapter); only the numbers the label's template shows
count now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4985: STYLEREF takes a label's numbers by their spans

Review: \s took the last word of a label (Response of "3 Response") and fell
back on the heading count for a none level showing its parent's number (%1);
\t kept text attached to a number (Section3). A label's numbers are now the
spans of the values its template shows, with their delimiters (a letter number
only standing apart from other letters); \s takes the last of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4985: tests edit the numbering through Arc::make_mut after merging main

Main shares the numbering between undo steps (#2694), so `Document::numbering` is an
`Arc<Numbering>`; the tests this PR adds wrote to it directly and no longer built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: MeHail <mihailsmetanin15@gmail.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:20:49 +01:00

333 lines
16 KiB
Rust

//! STYLEREF fields in headers and footers (#775): each page shows its own result.
use crate::*;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{CharProps, NumRef};
use wordcraft_doc::{ListKind, PartKind, Pos, para_block};
fn field(instr: &str) -> InlineObject {
InlineObject::Field { instr: instr.into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() }
}
/// Body paragraphs: `(style, text, page break before)`; a header with one STYLEREF per code.
fn doc(paras: &[(&str, &str, bool)], codes: &[&str]) -> Document {
let mut d = Document::from_text(&paras.iter().map(|p| p.1).collect::<Vec<_>>().join("\n"));
for (i, (style, _, brk)) in paras.iter().enumerate() {
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
p.props.style = Some((*style).to_string());
p.props.page_break_before = Some(*brk);
}
let blocks = codes
.iter()
.map(|c| {
let mut p = Paragraph::new();
p.insert_object(0, field(c), &CharProps::default()).unwrap();
para_block(p)
})
.collect();
let id = d.add_part(PartKind::Header, blocks);
d.last_section.headers.default = Some(id);
d
}
fn shown(items: &[Placed]) -> Vec<String> {
items.iter().flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] }).collect()
}
fn headers(d: &Document) -> Vec<Vec<String>> {
let mut c = LayoutCache::new();
layout(d, &mut c, &LayoutOptions::default()).pages.iter().map(|p| shown(&p.header)).collect()
}
const CHAPTERS: &[(&str, &str, bool)] = &[
("Heading1", "Beginnings", false),
("Normal", "Text of one.", false),
("Heading1", "Middles", true),
("Normal", "Text of two.", false),
// A page with no heading: the header keeps the chapter before it.
("Normal", "More of two.", true),
("Heading1", "Endings", true),
("Heading2", "A section", false),
];
#[test]
fn each_page_s_header_shows_its_chapter() {
let d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1" \* MERGEFORMAT"#, "STYLEREF 1", "STYLEREF heading1 \\l"]);
let h = headers(&d);
assert_eq!(h.len(), 4);
for (page, want) in ["Beginnings", "Middles", "Middles", "Endings"].iter().enumerate() {
assert_eq!(h[page], [*want, *want, *want], "page {}", page + 1);
}
}
#[test]
fn search_from_the_bottom_with_l() {
let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]);
assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]);
}
#[test]
fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
let d = doc(CHAPTERS, &[r#"STYLEREF "No Such Style""#, r#"STYLEREF "Heading 3""#, "STYLEREF 12"]);
let err = styleref::NO_TEXT;
assert_eq!(headers(&d)[0], [err, err, err]);
assert_eq!(err, "Error: no paragraph in this style.");
}
#[test]
fn before_the_first_heading_the_next_one_shows() {
let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)];
let d = doc(&paras, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]);
}
#[test]
fn character_styles_and_paragraph_numbers() {
let mut d = doc(CHAPTERS, &[r#"STYLEREF Strong"#, r#"STYLEREF "Heading 1" \n"#, r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 1" \s"#]);
// Bold a word on page 2 with the Strong character style.
d.format_range(&Pos::body(3, 8), &Pos::body(3, 11), &|c: &mut CharProps| c.style = Some("Strong".into())).unwrap();
// Number the headings 1., 2., 3. and the section under them 3.1 (full context).
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
}
let h = headers(&d);
assert_eq!(h[1], ["two", "2", "3.a", "2"]);
assert_eq!(h[3], ["two", "3", "3.a", "3"]);
}
/// #4985: a heading on a "none" list level (its label is its text, `Note`) shows its parent's
/// number in full context (`\w`): the parent is not taken for the heading's own level, which
/// has no number to list.
#[test]
fn full_context_keeps_the_parent_of_a_none_level_heading() {
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
let note = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: "Note".into(), ..Default::default() };
n.level_overrides = vec![(1, note)];
}
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
}
assert_eq!(headers(&d)[3], ["3.Note", "3.Note"]);
}
/// #4985 review: `\t` leaves a "none" level's label, which holds no number, with nothing, and an
/// empty label of its own adds no part in full context: `3`, not `3.`.
#[test]
fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
let numbered = |text: &str, codes: &[&str]| {
let mut d = doc(CHAPTERS, codes);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
let level = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: text.into(), ..Default::default() };
n.level_overrides = vec![(1, level)];
}
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
}
headers(&d)[3].clone()
};
assert!(numbered("Note", &[r#"STYLEREF "Heading 2" \n \t"#]).is_empty(), "nothing is drawn");
// `\s` is a chapter number: a "none" label isn't one, so the heading's count stands in; a
// label showing its parent's number (`%1`, `%1 Response`) gives that number.
assert_eq!(numbered("Note", &[r#"STYLEREF "Heading 2" \s"#]), ["1"]);
assert_eq!(numbered("%1", &[r#"STYLEREF "Heading 2" \s"#]), ["3"]);
assert_eq!(numbered("%1 Response", &[r#"STYLEREF "Heading 2" \s"#, r#"STYLEREF "Heading 2" \n \t"#]), ["3", "3"]);
assert_eq!(numbered("Section%1", &[r#"STYLEREF "Heading 2" \n \t"#]), ["3"]);
assert_eq!(numbered("", &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]), ["3", "3"]);
}
/// Editing a heading updates the headers that show it, through the paragraph cache.
#[test]
fn headers_follow_heading_edits() {
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
let mut c = LayoutCache::new();
let first = layout(&d, &mut c, &LayoutOptions::default());
assert_eq!(shown(&first.pages[1].header), ["Middles"]);
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
p.insert_text(0, "The ", &CharProps::default()).unwrap();
let again = layout(&d, &mut c, &LayoutOptions::default());
assert_eq!(shown(&again.pages[1].header), ["The Middles"]);
assert_eq!(shown(&again.pages[0].header), ["Beginnings"]);
}
/// The style index is built once and reused while the body and the styles stay the same (long
/// documents lay out on every keystroke); an edited heading or a changed style rebuilds it.
#[test]
fn style_index_is_reused_until_the_body_or_styles_change() {
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
let mut c = LayoutCache::new();
let index = |c: &LayoutCache| c.styleref.as_ref().map(|(_, _, ix)| ix.clone()).unwrap();
let pages =
|d: &Document, c: &mut LayoutCache| layout(d, c, &LayoutOptions::default()).pages.iter().map(|p| shown(&p.header)).collect::<Vec<_>>();
let before = pages(&d, &mut c);
let first = index(&c);
assert_eq!(pages(&d, &mut c), before);
assert!(Arc::ptr_eq(&first, &index(&c)), "nothing changed: the same index");
// Rename the first chapter: rebuilt, and the header shows the new title.
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.text = "Openings".into();
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }];
let after = pages(&d, &mut c);
assert!(!Arc::ptr_eq(&first, &index(&c)), "an edited heading rebuilds it");
assert!(after[0].iter().any(|t| t.contains("Openings")), "{after:?}");
// A style change rebuilds it too.
let second = index(&c);
if let Some(st) = Arc::make_mut(&mut d.styles).styles.iter_mut().find(|s| s.id == "Heading1") {
st.name = "Chapter Title".into();
}
let _ = pages(&d, &mut c);
assert!(!Arc::ptr_eq(&second, &index(&c)), "a style change rebuilds it");
}
/// `STYLEREF 1` finds the style named `heading 1` when its id is localized (#1286).
#[test]
fn heading_level_matches_a_localized_style_id() {
let paras: Vec<(&str, &str, bool)> = CHAPTERS.iter().map(|(s, t, b)| (if *s == "Heading1" { "berschrift1" } else { *s }, *t, *b)).collect();
let mut d = doc(&paras, &["STYLEREF 1"]);
let st = Arc::make_mut(&mut d.styles).styles.iter_mut().find(|s| s.id == "Heading1").unwrap();
st.id = "berschrift1".into();
st.name = "heading 1".into();
let h = headers(&d);
assert_eq!(h[0], ["Beginnings"]);
assert_eq!(h[3], ["Endings"]);
}
/// Typing in body text that no STYLEREF shows keeps every header paragraph cached (#1286); an
/// edited heading still updates the headers that show it.
#[test]
fn body_edits_keep_styleref_headers_cached() {
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
let mut c = LayoutCache::new();
let opts = LayoutOptions::default();
let _ = layout(&d, &mut c, &opts);
// Type in a body paragraph: only that paragraph is laid out again.
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_text(0, "More ", &CharProps::default()).unwrap();
let misses = c.misses;
let again = layout(&d, &mut c, &opts);
assert_eq!(c.misses, misses + 1, "only the edited body paragraph misses the cache");
assert_eq!(shown(&again.pages[1].header), ["Middles", "below"]);
// Edit a heading: the headers showing it change.
d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().insert_text(0, "The ", &CharProps::default()).unwrap();
let edited = layout(&d, &mut c, &opts);
assert_eq!(shown(&edited.pages[1].header), ["The Middles", "below"]);
// Page 3 has no heading: the one before it shows, above the header.
assert_eq!(shown(&edited.pages[2].header), ["The Middles", "above"]);
assert_eq!(shown(&edited.pages[0].header), ["Beginnings", "below"]);
}
/// The x of each shown character's first glyph in a header field result, in text order.
fn field_char_xs(d: &Document) -> Vec<(String, Vec<f32>)> {
let mut c = LayoutCache::new();
let l = layout(d, &mut c, &LayoutOptions::default());
let mut out = Vec::new();
for it in &l.pages[0].header {
let Placed::Lines { para, .. } = it else { continue };
for (k, text, ranges) in &para.shown {
let cl = &para.clusters[*k];
let glyphs = &para.glyphs[cl.g0 as usize..cl.g1 as usize];
let xs = text
.char_indices()
.map(|(i, _)| glyphs.iter().zip(ranges).find(|(_, r)| r.start == i).map(|(g, _)| g.dx).unwrap_or(f32::NAN))
.collect();
out.push((text.clone(), xs));
}
}
out
}
/// A STYLEREF showing a Hebrew heading reads right to left in the header like ordinary text
/// (#1670): "פרק א" draws פ rightmost, not reversed as "א קרפ". Latin results keep their order,
/// also in a right-to-left header paragraph.
#[test]
fn hebrew_styleref_result_reads_right_to_left() {
let d = doc(&[("Heading1", "פרק א", false), ("Normal", "Text.", false)], &[r#"STYLEREF "Heading 1""#]);
let got = field_char_xs(&d);
let (text, xs) = &got[0];
assert_eq!(text, "פרק א");
assert!(xs.iter().all(|x| x.is_finite()), "every letter has a glyph: {xs:?}");
assert!(xs.windows(2).all(|w| w[0] > w[1]), "letters in text order run right to left: {xs:?}");
// A Latin heading in a right-to-left header paragraph still reads left to right.
let mut d = doc(&[("Heading1", "Part one", false)], &[r#"STYLEREF "Heading 1""#]);
let hdr = d.last_section.headers.default.unwrap();
d.para_mut(StoryRef::Part(hdr), &Path::top(0)).unwrap().props.bidi = Some(true);
let got = field_char_xs(&d);
let (text, xs) = &got[0];
assert_eq!(text, "Part one");
assert!(xs.windows(2).all(|w| w[0] < w[1]), "Latin letters run left to right: {xs:?}");
}
/// What each header field result draws, read left to right: each glyph's character, a bracket
/// read as the bracket its glyph shows (a mirrored `(` reads `)`).
fn field_drawn(d: &Document, opts: &LayoutOptions) -> Vec<String> {
let mirror = |c: char| match c {
'(' => ')',
')' => '(',
'{' => '}',
'}' => '{',
'[' => ']',
']' => '[',
c => c,
};
let mut c = LayoutCache::new();
let l = layout(d, &mut c, opts);
let mut out = Vec::new();
for it in &l.pages[0].header {
let Placed::Lines { para, .. } = it else { continue };
for (k, text, ranges) in &para.shown {
let cl = &para.clusters[*k];
// A fallback face's glyphs share the cluster, so a glyph is matched in every face.
let gids = |ch: char| -> Vec<Option<u32>> {
para.styles.iter().map(|st| wordcraft_fonts::shape_run(&st.face, &ch.to_string(), &[], |c| c, false).first().map(|g| g.gid)).collect()
};
let glyphs = &para.glyphs[cl.g0 as usize..cl.g1 as usize];
let mut drawn: Vec<(f32, char)> = glyphs
.iter()
.zip(ranges)
.map(|(g, r)| {
let ch = text[r.start..].chars().next().unwrap();
let m = mirror(ch);
let is = |ch: char| gids(ch).contains(&Some(g.gid));
let shown = if m != ch && is(m) && !is(ch) { m } else { ch };
(g.dx, shown)
})
.collect();
drawn.sort_by(|a, b| a.0.total_cmp(&b.0));
out.push(drawn.into_iter().map(|d| d.1).collect());
}
}
out
}
fn rtl_header(d: &mut Document) {
let hdr = d.last_section.headers.default.unwrap();
d.para_mut(StoryRef::Part(hdr), &Path::top(0)).unwrap().props.bidi = Some(true);
}
/// Brackets in a field result are mirrored and placed by the same bidi run (#1670 review): a
/// field code or a Latin result in a right-to-left header keeps its brackets as written, and a
/// Hebrew word in brackets in a left-to-right result draws its brackets the right way round.
#[test]
fn field_results_mirror_brackets_with_their_bidi_runs() {
let mut got = Vec::new();
// The field-code view of a PAGE field in a right-to-left header.
let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]);
rtl_header(&mut d);
got.extend(field_drawn(&d, &LayoutOptions { field_codes: true, ..LayoutOptions::default() }));
// A Latin result with brackets, and digits, in a right-to-left header.
for heading in ["(Chapter 1)", "12"] {
let mut d = doc(&[("Heading1", heading, false)], &[r#"STYLEREF "Heading 1""#]);
rtl_header(&mut d);
got.extend(field_drawn(&d, &LayoutOptions::default()));
}
// A Hebrew word in brackets at the end of a left-to-right result, and the Hebrew heading.
for heading in ["Part 1 (פרק)", "פרק א"] {
let d = doc(&[("Heading1", heading, false)], &[r#"STYLEREF "Heading 1""#]);
got.extend(field_drawn(&d, &LayoutOptions::default()));
}
assert_eq!(got, ["{ PAGE }", "(Chapter 1)", "12", "Part 1 (קרפ)", "א קרפ"]);
}