mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
A hyperlinked TOC wrote each entry's page number as plain text inside the link. Word writes it as a PAGEREF _Toc… \h field, so Update Field in Word moves the number with its heading; with plain text the numbers stay what they were when the file was saved. Entries that link to a bookmark now end with that field (result: the page number). Entries without a link and \n levels are unchanged. The DOCX span test looks for the TOC's own end, the last one, since PAGEREF fields now end inside it. Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
560 lines
26 KiB
Rust
560 lines
26 KiB
Rust
use super::{
|
||
Block, CharProps, CmdError, CmdResult, Counters, FieldCode, InlineObject, LevelSuffix, NumFormat, OBJ, Paragraph, Path, Pos, Selection, Session,
|
||
StoryRef, TabAlign, TabLeader, TabStop, Value, delete_selection, p, sel_result,
|
||
};
|
||
|
||
/// A level range such as `1-3` (or one level, `2`), clamped to 1–9.
|
||
pub fn level_range(s: &str) -> Option<(u8, u8)> {
|
||
let mut it = s.split('-').map(|x| x.trim().parse::<u8>().ok());
|
||
let a = it.next().flatten()?.clamp(1, 9);
|
||
let b = match it.next() {
|
||
Some(b) => b?.clamp(1, 9),
|
||
None => a,
|
||
};
|
||
Some((a.min(b), a.max(b)))
|
||
}
|
||
|
||
/// The tab leader named in a command parameter.
|
||
pub fn leader(name: &str) -> TabLeader {
|
||
match name {
|
||
"none" => TabLeader::None,
|
||
"hyphen" => TabLeader::Hyphen,
|
||
"underscore" => TabLeader::Underscore,
|
||
"middleDot" => TabLeader::MiddleDot,
|
||
_ => TabLeader::Dot,
|
||
}
|
||
}
|
||
|
||
/// The TOC field instruction for `references.toc`'s parameters (ECMA-376 §17.16.5.68): `\o`
|
||
/// heading styles, `\h` hyperlinks, `\z` hide tab and page numbers in Web view, `\u` outline
|
||
/// levels, `\f`/`\l` TC fields, `\n` without page numbers, `\p` separator, `\t` styles with levels.
|
||
pub fn toc_instr(v: &Value) -> String {
|
||
let levels = p::u64(v, "levels").unwrap_or(3).clamp(1, 9);
|
||
let on = |k: &str, d: bool| p::bool(v, k).unwrap_or(d);
|
||
let mut s = String::from("TOC");
|
||
if on("headingStyles", true) {
|
||
s.push_str(&format!(" \\o \"1-{levels}\""));
|
||
}
|
||
if on("hyperlinks", true) {
|
||
s.push_str(" \\h");
|
||
}
|
||
s.push_str(" \\z");
|
||
if on("outlineLevels", true) {
|
||
s.push_str(" \\u");
|
||
}
|
||
if on("tcFields", false) {
|
||
s.push_str(&format!(" \\f \\l \"1-{levels}\""));
|
||
}
|
||
if !on("pageNumbers", true) {
|
||
s.push_str(&format!(" \\n \"1-{levels}\""));
|
||
} else if !on("rightAlign", true) {
|
||
s.push_str(" \\p \" \"");
|
||
}
|
||
let clean = |n: &str| n.chars().filter(|c| !matches!(c, '"' | ',' | '\\')).take(64).collect::<String>().trim().to_string();
|
||
let pairs: Vec<String> = match v.get("styleLevels") {
|
||
Some(Value::Object(m)) => m
|
||
.iter()
|
||
.take(64)
|
||
.filter_map(|(name, l)| Some((clean(name), l.as_u64()?.clamp(1, 9))))
|
||
.filter(|(n, _)| !n.is_empty())
|
||
.map(|(n, l)| format!("{n},{l}"))
|
||
.collect(),
|
||
_ => Vec::new(),
|
||
};
|
||
if !pairs.is_empty() {
|
||
s.push_str(&format!(" \\t \"{}\"", pairs.join(",")));
|
||
}
|
||
s
|
||
}
|
||
|
||
/// A TOC entry: the paragraph it points at, its level (0-based), its text, and the leading page
|
||
/// breaks of that paragraph (its page is where the text after them lands).
|
||
struct TocEntry {
|
||
path: Path,
|
||
level: u8,
|
||
text: String,
|
||
lead: usize,
|
||
}
|
||
|
||
/// The headings of the body, in document order, as (body block, level 1–9, text, leading page
|
||
/// breaks): what Insert › Link › Place in This Document lists.
|
||
pub(crate) fn headings(s: &Session) -> Vec<(usize, u8, String, usize)> {
|
||
let f = FieldCode::parse("TOC \\o \"1-9\" \\u");
|
||
toc_entries(s, &f).into_iter().filter(|e| e.path.depth() == 0).map(|e| (e.path.last(), e.level.saturating_add(1), e.text, e.lead)).collect()
|
||
}
|
||
|
||
/// The heading level (1–9) of a built-in heading style.
|
||
pub(super) fn heading_style_level(p: &Paragraph) -> Option<u8> {
|
||
p.props.style.as_deref()?.strip_prefix("Heading")?.trim().parse::<u8>().ok().filter(|l| (1..=9).contains(l))
|
||
}
|
||
|
||
/// The entries a TOC field collects from the body, in document order: built-in heading styles
|
||
/// in the `\o` range, paragraphs with an outline level (`\u`), styles listed in `\t`, and TC
|
||
/// fields (`\f`, levels `\l`).
|
||
fn toc_entries(s: &Session, f: &FieldCode) -> Vec<TocEntry> {
|
||
let o = f.has('o').then(|| f.arg('o').and_then(level_range).unwrap_or((1, 9)));
|
||
let max = o.map(|r| r.1).unwrap_or(9);
|
||
let styles: Vec<(String, u8)> = f
|
||
.arg('t')
|
||
.map(|t| {
|
||
let parts: Vec<&str> = t.split([',', ';']).map(str::trim).collect();
|
||
parts.chunks(2).filter_map(|c| Some((c.first()?.to_string(), c.get(1)?.parse::<u8>().ok()?.clamp(1, 9)))).collect()
|
||
})
|
||
.unwrap_or_default();
|
||
let tc = f.has('f').then(|| f.arg('f').unwrap_or("").to_ascii_uppercase());
|
||
let tc_levels = f.arg('l').and_then(level_range).unwrap_or((1, 9));
|
||
let mut v = Vec::new();
|
||
// Every numbered paragraph advances the list counters, so an entry carries the number its
|
||
// heading shows (`1.1`), followed by the level's separator, as Word writes it.
|
||
let mut counters = Counters::default();
|
||
for path in s.doc.para_paths(StoryRef::Body) {
|
||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||
let number = p
|
||
.props
|
||
.numbering
|
||
.or_else(|| s.doc.styles.resolve_para(&p.props).numbering)
|
||
.filter(|n| n.num != 0)
|
||
.and_then(|n| counters.next_label(&s.doc.numbering, n.num, n.level))
|
||
.filter(|(label, def)| !label.is_empty() && !matches!(def.format, NumFormat::Bullet | NumFormat::None))
|
||
.map(|(label, def)| match def.suffix {
|
||
LevelSuffix::Tab => format!("{label}\t"),
|
||
LevelSuffix::Space => format!("{label} "),
|
||
LevelSuffix::Nothing => label,
|
||
});
|
||
let style = p.props.style.as_deref().unwrap_or("Normal");
|
||
if style.starts_with("TOC") {
|
||
continue;
|
||
}
|
||
let lead = p.text.bytes().take_while(|b| *b == 0x0C).count();
|
||
let by_style =
|
||
styles.iter().find(|(n, _)| s.doc.styles.get(style).is_some_and(|st| st.name.eq_ignore_ascii_case(n)) || n == style).map(|(_, l)| *l);
|
||
// `Some(9)` direct outline level is body text: "Do Not Show in Table of Contents".
|
||
let by_outline = f.has('u').then_some(p.props.outline_level).flatten().filter(|l| *l < 9).map(|l| l + 1).filter(|l| *l <= max);
|
||
let hidden = f.has('u') && p.props.outline_level == Some(9);
|
||
let by_heading = o.and_then(|(a, b)| {
|
||
let l = heading_style_level(p).or_else(|| {
|
||
s.doc
|
||
.styles
|
||
.resolve_para(&wordcraft_doc::ParaProps { style: p.props.style.clone(), ..Default::default() })
|
||
.outline_level
|
||
.map(|l| l + 1)
|
||
})?;
|
||
(a..=b).contains(&l).then_some(l)
|
||
});
|
||
if let Some(l) = by_style.or(by_outline).or(if hidden { None } else { by_heading }) {
|
||
let t = p.plain_text().replace(['\u{000C}', '\n', '\t'], " ").trim().to_string();
|
||
if !t.is_empty() {
|
||
let text = number.map_or_else(|| t.clone(), |n| format!("{n}{t}"));
|
||
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead });
|
||
}
|
||
}
|
||
if let Some(id) = &tc {
|
||
for off in p.object_offsets() {
|
||
let Some(InlineObject::Field { instr, .. }) = p.object_at(off) else { continue };
|
||
let c = FieldCode::parse(instr);
|
||
if c.name != "TC" {
|
||
continue;
|
||
}
|
||
let kind = c.arg('f').unwrap_or("C").to_ascii_uppercase();
|
||
let l = c.arg('l').and_then(|l| l.trim().parse::<u8>().ok()).unwrap_or(1).clamp(1, 9);
|
||
let text = c.args.first().map(|t| t.trim().to_string()).unwrap_or_default();
|
||
if (id.is_empty() || *id == kind) && (tc_levels.0..=tc_levels.1).contains(&l) && !text.is_empty() {
|
||
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead: off });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// The TOC block range in the body: the paragraph holding the TOC field and the TOC entries after
|
||
/// it. A TOC with `\c` is a table of figures, kept by the generated lists.
|
||
pub(super) fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||
let start = s.doc.body.iter().position(|b| b.as_para().is_some_and(|p| toc_field(p).is_some()))?;
|
||
let mut end = start;
|
||
while let Some(Block::Para(p)) = s.doc.body.get(end + 1).map(|b| &**b) {
|
||
if p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading") {
|
||
end += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
Some((start, end))
|
||
}
|
||
|
||
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
|
||
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
|
||
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
|
||
/// with the table.
|
||
pub(super) fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
|
||
for i in (start + 1..=end).rev() {
|
||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||
}
|
||
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
|
||
Ok(p) if p.section.is_some() => {
|
||
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
|
||
start + 1
|
||
}
|
||
_ => {
|
||
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
|
||
start
|
||
}
|
||
};
|
||
let titled = start
|
||
.checked_sub(1)
|
||
.and_then(|i| s.doc.body.get(i))
|
||
.and_then(|b| b.as_para())
|
||
.is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none());
|
||
if titled {
|
||
s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?;
|
||
return Ok(at - 1);
|
||
}
|
||
Ok(at)
|
||
}
|
||
|
||
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
|
||
/// offers Update Field for the whole table.
|
||
pub fn caret_in_toc(s: &Session) -> bool {
|
||
let at = &s.sel.focus;
|
||
if at.story != StoryRef::Body || at.path.depth() != 0 {
|
||
return false;
|
||
}
|
||
if toc_range(s).is_some_and(|(start, end)| (start..=end).contains(&at.path.last())) {
|
||
return true;
|
||
}
|
||
// A saved or imported TOC is a range field spanning its entries until Update Table turns it
|
||
// into the engine's TOC (#2182, a save and reopen; not the flow in the report). Only the
|
||
// range Update Table would convert counts, so the menu never refreshes a different TOC.
|
||
s.doc.field_ranges(StoryRef::Body).into_iter().find(|r| convertible_toc_range(s, r)).is_some_and(|r| {
|
||
// The caret at the field's own marker (offset 0 of the first entry) and at the end of
|
||
// the last entry belong to the range as well.
|
||
(r.start.path.last()..=r.end.path.last()).contains(&at.path.last())
|
||
})
|
||
}
|
||
|
||
/// The range field `range_toc_to_field` converts: an unlocked, top-level TOC (not a table of
|
||
/// figures) when the body has no engine TOC yet.
|
||
fn convertible_toc_range(s: &Session, r: &wordcraft_doc::FieldRange) -> bool {
|
||
let field = FieldCode::parse(&r.instr);
|
||
toc_range(s).is_none()
|
||
&& field.name == "TOC"
|
||
&& !field.is_table_of_figures()
|
||
&& r.depth == 0
|
||
&& !r.locked
|
||
&& r.start.path.depth() == 0
|
||
&& r.end.path.depth() == 0
|
||
}
|
||
|
||
/// A TOC imported as a range field (`FieldStart`…`FieldEnd`: one without a TOC heading, or with
|
||
/// content around it the engine's TOC can't reproduce) becomes the engine's TOC before an update,
|
||
/// so Update Table refreshes it rather than adding a second one: an empty `TOC` field where the
|
||
/// range started, its old result gone, and the regenerated entries after that paragraph (text
|
||
/// that followed the range moves below them). Only an unlocked, top-level TOC that isn't inside
|
||
/// another range field, and only when the body has no engine TOC already.
|
||
fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||
if toc_range(s).is_some() {
|
||
return Ok(());
|
||
}
|
||
let Some(r) = s.doc.field_ranges(StoryRef::Body).into_iter().find(|r| convertible_toc_range(s, r)) else {
|
||
return Ok(());
|
||
};
|
||
let Some(after_end) = r.end.off.checked_add(OBJ.len_utf8()) else { return Ok(()) };
|
||
let end = Pos { off: after_end, ..r.end.clone() };
|
||
let props = s.doc.para_at(&r.start).map(|p| p.props_of_char(r.start.off).clone()).unwrap_or_default();
|
||
let mut at = s.doc.delete_range(&r.start, &end)?;
|
||
// A range that filled its own paragraphs right under a TOC heading (as Word and other writers
|
||
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
|
||
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
|
||
let i = at.path.last();
|
||
// Or one holding only the end of Word's TOC control, still styled as an entry: Update Table
|
||
// would delete it with the old entries anyway.
|
||
let leftover = |p: &Paragraph| {
|
||
p.is_empty()
|
||
|| (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd))
|
||
&& p.text.chars().all(|c| c == OBJ)
|
||
&& p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||
};
|
||
if at.path.depth() == 0
|
||
&& i > 0
|
||
&& s.doc.para_at(&at).is_some_and(leftover)
|
||
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
|
||
// field was (the field is saved opening in the first entry), so the next save and update
|
||
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
|
||
// range TOC is reused the same way, so deleting it later deletes the TOC field. So is one
|
||
// ending the section before, as above entries in columns (#3889).
|
||
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
|
||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty())
|
||
})
|
||
{
|
||
s.doc.remove_block(StoryRef::Body, &at.path)?;
|
||
let heading = Path::top(i - 1);
|
||
let len = s.doc.para(StoryRef::Body, &heading).map_or(0, |p| p.len());
|
||
at = Pos { story: StoryRef::Body, path: heading, off: len };
|
||
}
|
||
let field = InlineObject::Field {
|
||
instr: r.instr.trim().to_string(),
|
||
result: String::new(),
|
||
locked: false,
|
||
dirty: false,
|
||
code: false,
|
||
form: String::new(),
|
||
};
|
||
let after = s.doc.insert_object(&at, field, &props)?;
|
||
if s.doc.para_at(&after).is_some_and(|p| after.off < p.len()) {
|
||
s.doc.split_paragraph(&after)?;
|
||
}
|
||
s.clamp_selection();
|
||
Ok(())
|
||
}
|
||
|
||
/// The TOC field instruction in `p` (not a table of figures).
|
||
fn toc_field(p: &Paragraph) -> Option<String> {
|
||
p.objects.iter().find_map(|o| match o {
|
||
InlineObject::Field { instr, .. } => {
|
||
let f = FieldCode::parse(instr);
|
||
(f.name == "TOC" && !f.is_table_of_figures()).then(|| instr.clone())
|
||
}
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// The first cached number-separator tab formatting at each TOC level.
|
||
pub(super) 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.is_table_of_figures())
|
||
.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
|
||
}
|
||
|
||
pub(super) 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.
|
||
let mut placeholder = false;
|
||
let at = match toc_range(s) {
|
||
// `{}` updates the table there; settings replace it where it is.
|
||
Some(_) if !replace => {
|
||
regenerate_toc(s, None, &tabs)?;
|
||
return sel_result(s);
|
||
}
|
||
Some((start, end)) => {
|
||
let start = remove_toc(s, start, end)?;
|
||
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
|
||
placeholder = true;
|
||
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
|
||
s.sel.focus.clone()
|
||
}
|
||
None => delete_selection(s)?,
|
||
};
|
||
let title = p::str(v, "title").unwrap_or("Contents").to_string();
|
||
if at.story != StoryRef::Body || at.path.depth() > 0 {
|
||
return Err(CmdError::Failed("a table of contents goes in the main text".into()));
|
||
}
|
||
// Title paragraph carrying the TOC field marker.
|
||
let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading");
|
||
head.insert_object(
|
||
head.len(),
|
||
InlineObject::Field { instr: toc_instr(v), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||
&CharProps::default(),
|
||
)?;
|
||
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) {
|
||
if placeholder {
|
||
s.doc.remove_block(StoryRef::Body, &at.path)?;
|
||
}
|
||
at.path.last()
|
||
} else {
|
||
s.doc.split_paragraph(&at)?.path.last()
|
||
};
|
||
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), &tabs)?;
|
||
s.clamp_selection();
|
||
sel_result(s)
|
||
}
|
||
|
||
/// A hidden `_Toc` bookmark around the heading at body block `i` (after its leading page breaks),
|
||
/// reusing one already there; its name.
|
||
pub(crate) fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result<String, CmdError> {
|
||
bookmark_heading(s, &Path::top(i), lead)
|
||
}
|
||
|
||
fn bookmark_heading(s: &mut Session, path: &Path, lead: usize) -> Result<String, CmdError> {
|
||
const MARK: usize = wordcraft_doc::para::OBJ.len_utf8();
|
||
let para = s.doc.para(StoryRef::Body, path).ok_or_else(|| CmdError::Failed("heading is gone".into()))?;
|
||
if let Some(InlineObject::BookmarkStart { name }) = para.object_at(lead)
|
||
&& name.starts_with("_Toc")
|
||
{
|
||
return Ok(name.clone());
|
||
}
|
||
let next =
|
||
s.doc.bookmarks().iter().filter_map(|(n, _)| n.strip_prefix("_Toc").and_then(|d| d.parse::<u64>().ok())).max().unwrap_or(0).saturating_add(1);
|
||
let name = format!("_Toc{next:09}");
|
||
let end = para.len();
|
||
let props = CharProps::default();
|
||
let pos = |off| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||
s.doc.insert_object(&pos(end), InlineObject::BookmarkEnd { name: name.clone() }, &props)?;
|
||
s.doc.insert_object(&pos(lead), InlineObject::BookmarkStart { name: name.clone() }, &props)?;
|
||
// The caret in that paragraph stays on the same text.
|
||
let shift = |p: &mut Pos| {
|
||
if p.story == StoryRef::Body && &p.path == path {
|
||
if p.off > end {
|
||
p.off = p.off.saturating_add(2 * MARK);
|
||
} else if p.off >= lead && p.off > 0 {
|
||
p.off = p.off.saturating_add(MARK);
|
||
}
|
||
}
|
||
};
|
||
shift(&mut s.sel.anchor);
|
||
shift(&mut s.sel.focus);
|
||
Ok(name)
|
||
}
|
||
|
||
/// Regenerate the TOC entries with page numbers (two layout passes: entries change pagination).
|
||
pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
|
||
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>, 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(()) };
|
||
// A TOC field imported with an inline placeholder/cached result must have its result cleared so
|
||
// that the engine's TOC shape matches `toc_span` and the DOCX writer spans the field across entries.
|
||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
|
||
for o in &mut p.objects {
|
||
if let InlineObject::Field { instr, result, .. } = o {
|
||
let f = FieldCode::parse(instr);
|
||
if f.name == "TOC" && !f.is_table_of_figures() {
|
||
result.clear();
|
||
}
|
||
}
|
||
}
|
||
p.touch();
|
||
}
|
||
let f = FieldCode::parse(&instr);
|
||
let leader = leader.unwrap_or_else(|| {
|
||
s.doc
|
||
.body
|
||
.get(start + 1)
|
||
.filter(|_| end > start)
|
||
.and_then(|b| b.as_para())
|
||
.and_then(|p| p.props.tabs.as_ref()?.last().map(|t| t.leader))
|
||
.unwrap_or(TabLeader::Dot)
|
||
});
|
||
for i in (start + 1..=end).rev() {
|
||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||
}
|
||
// The column width of the TOC's own section, not the caret's: after Select All the caret sits
|
||
// at the end of the document, possibly in a wider (landscape) section. In columns, the first
|
||
// column's, as Word does (#3889); after a field paragraph ending a section, the next section's.
|
||
let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
|
||
let section = s.doc.section_index_of(start + usize::from(after_break));
|
||
let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w);
|
||
let width = s.doc.sections().get(section).map_or_else(|| column(&crate::cmd::page::sect(s)), |(_, x)| column(x));
|
||
let entries = toc_entries(s, &f);
|
||
let links: Vec<Option<String>> = if f.has('h') {
|
||
let mut v = Vec::new();
|
||
for e in &entries {
|
||
// TC fields link to their paragraph's start.
|
||
let lead = s.doc.para(StoryRef::Body, &e.path).map_or(0, |p| p.text.bytes().take_while(|b| *b == 0x0C).count());
|
||
v.push(Some(format!("#{}", bookmark_heading(s, &e.path, lead)?)));
|
||
}
|
||
v
|
||
} else {
|
||
vec![None; entries.len()]
|
||
};
|
||
let no_pages = f.has('n').then(|| f.arg('n').and_then(level_range).unwrap_or((1, 9)));
|
||
let sep = f.arg('p').map(str::to_string).unwrap_or_else(|| "\t".into());
|
||
for pass in 0..2 {
|
||
// Pass 1 lays out with the entries in place, so headings below the TOC sit `entries.len()`
|
||
// blocks further down and their pages account for the TOC's own length.
|
||
s.touch();
|
||
let l = s.layout();
|
||
if pass == 1 {
|
||
// Replace the page numbers now that the entries exist.
|
||
for _ in 0..entries.len() {
|
||
s.doc.remove_block(StoryRef::Body, &Path::top(start + 1))?;
|
||
}
|
||
}
|
||
let shift = if pass == 0 { 0 } else { entries.len() };
|
||
for (k, e) in entries.iter().enumerate() {
|
||
let mut target = e.path.clone();
|
||
if let Some(top) = target.0.first_mut()
|
||
&& (*top as usize) > start
|
||
{
|
||
*top = top.saturating_add(u32::try_from(shift).unwrap_or(u32::MAX));
|
||
}
|
||
let page =
|
||
l.caret(&Pos { story: StoryRef::Body, path: target, off: e.lead }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
|
||
let show_page = no_pages.is_none_or(|(a, b)| !(a..=b).contains(&(e.level + 1)));
|
||
let link = links.get(k).cloned().flatten();
|
||
// With a bookmark to point at, the page number is a `PAGEREF` field, as Word writes
|
||
// it, so updating it in Word follows the heading to its new page.
|
||
let pageref = link.as_deref().and_then(|l| l.strip_prefix('#')).filter(|_| show_page).map(|b| format!("PAGEREF {b} \\h"));
|
||
let text = match (show_page, &pageref) {
|
||
(true, None) => format!("{}{sep}{page}", e.text),
|
||
(true, Some(_)) => format!("{}{sep}", e.text),
|
||
(false, _) => e.text.clone(),
|
||
};
|
||
let props = CharProps { link, ..Default::default() };
|
||
let mut para = Paragraph::with_text(&text, props.clone()).styled(&format!("TOC{}", e.level + 1));
|
||
if let Some(instr) = pageref {
|
||
let field = InlineObject::Field { instr, result: page.to_string(), locked: false, dirty: false, code: false, form: String::new() };
|
||
para.insert_object(para.len(), field, &props)?;
|
||
}
|
||
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));
|
||
}
|
||
s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?;
|
||
}
|
||
}
|
||
s.touch();
|
||
Ok(())
|
||
}
|
||
|
||
/// The tab stops of a TOC entry: the right-aligned stop for its page number, and, when the
|
||
/// entry text itself has no tab, Clear stops for the left, centre and decimal stops its style
|
||
/// sets before that. Those stops are for a number set off by a tab (`1.1<tab>Title`); with the
|
||
/// 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 = 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 {
|
||
if t.pos < right.pos - 0.5 && !matches!(t.align, TabAlign::Right | TabAlign::Bar) {
|
||
tabs.push(TabStop { pos: t.pos, align: TabAlign::Clear, leader: TabLeader::None });
|
||
}
|
||
}
|
||
}
|
||
tabs.push(right);
|
||
tabs
|
||
}
|