mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-19 02:10:15 -05:00
TOC: keep direct number tabs on refresh (task 276) (#2475)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
09b822fdbe
commit
a5d817a9ef
3 files changed
+121
-5
No files matched your search
@@ -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, ¶.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)])]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user