TOC: keep direct number tabs on refresh (task 276) (#2475)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fin WarmanandBrandon Thomas authored and GitHub committed 2026-10-18 10:27:26 -04:00
1 parent 09b822fdbe
commit a5d817a9ef
3 files changed
+121 -5

No files matched your search

+57 -5
View File
@@ -431,7 +431,33 @@ fn toc_field(p: &Paragraph) -> Option<String> {
})
}
/// The first cached number-separator tab formatting at each TOC level.
fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec<TabStop>)> {
let span = toc_range(s).map(|(start, end)| (start.saturating_add(1), end)).or_else(|| {
s.doc.field_ranges(StoryRef::Body).into_iter().find_map(|r| {
let f = FieldCode::parse(&r.instr);
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.has('c'))
.then_some((r.start.path.last(), r.end.path.last()))
})
});
let Some(blocks) = span.and_then(|(start, end)| s.doc.body.get(start..=end)) else { return Vec::new() };
let mut tabs: Vec<(String, Vec<TabStop>)> = Vec::new();
for p in blocks.iter().filter_map(|b| b.as_para()) {
let Some(style) = &p.props.style else { continue };
let Some(direct) = &p.props.tabs else { continue };
if !direct.iter().any(|t| matches!(t.align, TabAlign::Left | TabAlign::Center | TabAlign::Decimal)) {
continue;
}
if !style.starts_with("TOC") || style == "TOCHeading" || tabs.iter().any(|(id, _)| id == style) {
continue;
}
tabs.push((style.clone(), direct.clone()));
}
tabs
}
fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
let tabs = cached_toc_tabs(s);
range_toc_to_field(s)?;
let replace = v.as_object().is_some_and(|m| !m.is_empty());
// Replacing a table puts the new one where the old one was, in place of an empty paragraph.
@@ -439,7 +465,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
let at = match toc_range(s) {
// `{}` updates the table there; settings replace it where it is.
Some(_) if !replace => {
update_toc(s)?;
regenerate_toc(s, None, &tabs)?;
return sel_result(s);
}
Some((start, end)) => {
@@ -474,7 +500,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
};
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
let leader = leader(p::str(v, "tabLeader").unwrap_or("dot"));
regenerate_toc(s, Some(leader))?;
regenerate_toc(s, Some(leader), &tabs)?;
s.clamp_selection();
sel_result(s)
}
@@ -518,12 +544,13 @@ fn bookmark_heading(s: &mut Session, path: &Path, lead: usize) -> Result<String,
/// Regenerate the TOC entries with page numbers (two layout passes: entries change pagination).
pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
regenerate_toc(s, None)
let tabs = cached_toc_tabs(s);
regenerate_toc(s, None, &tabs)
}
/// Rebuild the entries the TOC field's switches ask for. The tab leader is `leader`, or the one
/// the entries had (Word keeps it in the entries' tab stops, not in the field).
fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdError> {
fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, Vec<TabStop>)]) -> Result<(), CmdError> {
range_toc_to_field(s)?;
let Some((start, end)) = toc_range(s) else { return Ok(()) };
let Some(instr) = s.doc.body.get(start).and_then(|b| b.as_para()).and_then(toc_field) else { return Ok(()) };
@@ -597,6 +624,9 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
let props = CharProps { link: links.get(k).cloned().flatten(), ..Default::default() };
let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1));
para.mark = CharProps::default();
if e.text.contains('\t') {
para.props.tabs = tabs.iter().find(|(style, _)| Some(style) == para.props.style.as_ref()).map(|(_, tabs)| tabs.clone());
}
if show_page && sep.contains('\t') {
let right = TabStop { pos: width - 0.5, align: TabAlign::Right, leader };
para.props.tabs = Some(entry_tabs(s, &para.props, &e.text, right));
@@ -614,7 +644,8 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
/// number set off by a space, or no number, a short entry's page tab would stop at one of them
/// (without the leader), and so would the tab on the last line of an entry that wraps.
fn entry_tabs(s: &Session, props: &wordcraft_doc::ParaProps, text: &str, right: TabStop) -> Vec<TabStop> {
let mut tabs = Vec::new();
let mut tabs = props.tabs.clone().unwrap_or_default();
tabs.retain(|t| t.pos < right.pos - 0.5 && !matches!(t.align, TabAlign::Right | TabAlign::Bar));
if !text.contains('\t') {
let style = s.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: props.style.clone(), ..Default::default() });
for t in style.tabs {
@@ -1325,4 +1356,25 @@ mod tests {
// Junk values fall back to the caret's story.
s.run("references.updateFields", &json!({"all": "yes"})).unwrap();
}
#[test]
fn toc_tabs_ignore_nonentries() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Before\nContents\nOld\t9\nAfter"));
let stop = |pos| TabStop { pos, align: TabAlign::Left, leader: TabLeader::None };
for (i, pos) in [(0, 96.0), (1, 84.0), (2, 24.0)] {
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
p.props.style = Some("TOC2".into());
p.props.tabs = Some(vec![stop(pos)]);
}
s.doc
.para_mut(StoryRef::Body, &Path::top(1))
.unwrap()
.insert_object(
0,
InlineObject::Field { instr: "TOC".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
assert_eq!(cached_toc_tabs(&s), vec![("TOC2".into(), vec![stop(24.0)])]);
}
}
+61
View File
@@ -8162,3 +8162,64 @@ fn toc_collects_cell_headings() {
assert!(s.doc.para(StoryRef::Body, &Path::top(1)).is_some());
}
}
#[test]
fn toc_keeps_direct_number_tabs() {
use wordcraft_doc::props::{CharProps, NumRef, TabAlign, TabLeader, TabStop};
let left = TabStop { pos: 24.0, align: TabAlign::Left, leader: TabLeader::None };
let right = TabStop { pos: 179.5, align: TabAlign::Right, leader: TabLeader::Dot };
let mut d = wordcraft_doc::Document::from_text("Contents\n\n1.\tAlpha\t9\nAlpha\nBeta");
d.last_section.page_w = 220.0;
d.last_section.margin_left = 20.0;
d.last_section.margin_right = 20.0;
d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
{
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.props.style = Some("TOCHeading".into());
p.insert_object(
p.len(),
wordcraft_doc::InlineObject::Field {
instr: r#"TOC \o "1-2""#.into(),
result: String::new(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&CharProps::default(),
)
.unwrap();
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
p.props.style = Some("TOC2".into());
p.props.tabs = Some(vec![right]);
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2)).unwrap();
p.props.style = Some("TOC2".into());
p.props.tabs = Some(vec![left, right]);
}
for i in [3, 4] {
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
p.props.style = Some("Heading2".into());
p.props.numbering = Some(NumRef { num, level: 0 });
}
let bytes = wordcraft_docx::write(&d).unwrap();
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
for command in ["references.updateFields", "references.updateToc", "references.toc"] {
run(&mut s, command, json!({}));
let layout = s.layout();
let mut entries = 0;
for (i, b) in s.doc.body.iter().enumerate() {
let Some(p) = b.as_para().filter(|p| p.props.style.as_deref() == Some("TOC2")) else { continue };
entries += 1;
assert!(p.props.tabs.as_ref().is_some_and(|tabs| tabs.contains(&left)), "{command}: {:?}", p.props.tabs);
let title = p.text.find('\t').unwrap() + 1;
let page = p.text.rfind('\t').unwrap() + 1;
let start = layout.caret(&Pos::body(i, title)).unwrap();
let end = layout.caret(&Pos::body(i, page)).unwrap();
assert_eq!((start.page, start.top), (end.page, end.top), "{command}: {:?}", p.plain_text());
assert!((start.x - 44.0).abs() < 1.0, "{command}: {}", start.x);
assert!(end.x > 180.0, "{command}: {}", end.x);
}
assert_eq!(entries, 2);
}
}
+3
View File
@@ -191,6 +191,8 @@ When content exceeds a preferred column width, AutoFit donors lose equal widths
Behind-text pictures in table cells participate in centre and bottom vertical alignment without increasing row flow height.
TOC refresh reuses direct tab formatting from the first cached entry with a number-separator stop at each level for numbered entries, including imported range fields. Page-number tabs still use the TOC section width. No new default number-tab position is inferred.
## Revision history
| Date | Change | Summary |
@@ -218,6 +220,7 @@ Behind-text pictures in table cells participate in centre and bottom vertical al
| 2026-10-15 | trivial | Partial cell margins retain omitted edges through DOCX save and inherit current table defaults during layout. Original import/write and row-height regressions. |
| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
| 2026-10-16 | trivial | Zero-extent drawing anchors contribute no inline font height. Synthetic regressions. |
| 2026-10-16 | trivial | Preserve direct TOC number tabs during regeneration |
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |